feat: ability to add and delete files of existing share (#306)
* feat(share): delete file api, revert complete share api. * feat(share): share edit page. * feat(share): Modify the DropZone title of the edit sharing UI. * feat(share): i18n for edit share. (en, zh) * feat(share): allow creator get share by id. * feat(share): add edit button in account/shares. * style(share): lint. * chore: some minor adjustments. * refactor: run formatter * refactor: remove unused return --------- Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
@@ -81,4 +82,14 @@ export class FileController {
|
|||||||
|
|
||||||
return new StreamableFile(file.file);
|
return new StreamableFile(file.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(":fileId")
|
||||||
|
@SkipThrottle()
|
||||||
|
@UseGuards(ShareOwnerGuard)
|
||||||
|
async remove(
|
||||||
|
@Param("fileId") fileId: string,
|
||||||
|
@Param("shareId") shareId: string,
|
||||||
|
) {
|
||||||
|
await this.fileService.remove(shareId, fileId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,18 @@ export class FileService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async remove(shareId: string, fileId: string) {
|
||||||
|
const fileMetaData = await this.prisma.file.findUnique({
|
||||||
|
where: { id: fileId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fileMetaData) throw new NotFoundException("File not found");
|
||||||
|
|
||||||
|
fs.unlinkSync(`${SHARE_DIRECTORY}/${shareId}/${fileId}`);
|
||||||
|
|
||||||
|
await this.prisma.file.delete({ where: { id: fileId } });
|
||||||
|
}
|
||||||
|
|
||||||
async deleteAllFiles(shareId: string) {
|
async deleteAllFiles(shareId: string) {
|
||||||
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
await fs.promises.rm(`${SHARE_DIRECTORY}/${shareId}`, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -7,12 +6,21 @@ import {
|
|||||||
import { User } from "@prisma/client";
|
import { User } from "@prisma/client";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { JwtGuard } from "../../auth/guard/jwt.guard";
|
||||||
|
import { ConfigService } from "src/config/config.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShareOwnerGuard implements CanActivate {
|
export class ShareOwnerGuard extends JwtGuard {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(
|
||||||
|
configService: ConfigService,
|
||||||
|
private prisma: PrismaService,
|
||||||
|
) {
|
||||||
|
super(configService);
|
||||||
|
}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
|
if (!(await super.canActivate(context))) return false;
|
||||||
|
|
||||||
const request: Request = context.switchToHttp().getRequest();
|
const request: Request = context.switchToHttp().getRequest();
|
||||||
const shareId = Object.prototype.hasOwnProperty.call(
|
const shareId = Object.prototype.hasOwnProperty.call(
|
||||||
request.params,
|
request.params,
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ export class ShareController {
|
|||||||
return new ShareDTO().from(await this.shareService.get(id));
|
return new ShareDTO().from(await this.shareService.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(":id/from-owner")
|
||||||
|
@UseGuards(ShareOwnerGuard)
|
||||||
|
async getFromOwner(@Param("id") id: string) {
|
||||||
|
return new ShareDTO().from(await this.shareService.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id/metaData")
|
@Get(":id/metaData")
|
||||||
@UseGuards(ShareSecurityGuard)
|
@UseGuards(ShareSecurityGuard)
|
||||||
async getMetaData(@Param("id") id: string) {
|
async getMetaData(@Param("id") id: string) {
|
||||||
@@ -62,12 +68,6 @@ export class ShareController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
|
||||||
@UseGuards(JwtGuard, ShareOwnerGuard)
|
|
||||||
async remove(@Param("id") id: string) {
|
|
||||||
await this.shareService.remove(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(":id/complete")
|
@Post(":id/complete")
|
||||||
@HttpCode(202)
|
@HttpCode(202)
|
||||||
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
@UseGuards(CreateShareGuard, ShareOwnerGuard)
|
||||||
@@ -78,6 +78,18 @@ export class ShareController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(":id/complete")
|
||||||
|
@UseGuards(ShareOwnerGuard)
|
||||||
|
async revertComplete(@Param("id") id: string) {
|
||||||
|
return new ShareDTO().from(await this.shareService.revertComplete(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@UseGuards(ShareOwnerGuard)
|
||||||
|
async remove(@Param("id") id: string) {
|
||||||
|
await this.shareService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Throttle(10, 60)
|
@Throttle(10, 60)
|
||||||
@Get("isShareIdAvailable/:id")
|
@Get("isShareIdAvailable/:id")
|
||||||
async isShareIdAvailable(@Param("id") id: string) {
|
async isShareIdAvailable(@Param("id") id: string) {
|
||||||
|
|||||||
@@ -182,6 +182,13 @@ export class ShareService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async revertComplete(id: string) {
|
||||||
|
return this.prisma.share.update({
|
||||||
|
where: { id },
|
||||||
|
data: { uploadLocked: false, isZipReady: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getSharesByUser(userId: string) {
|
async getSharesByUser(userId: string) {
|
||||||
const shares = await this.prisma.share.findMany({
|
const shares = await this.prisma.share.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ const useStyles = createStyles((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const Dropzone = ({
|
const Dropzone = ({
|
||||||
|
title,
|
||||||
isUploading,
|
isUploading,
|
||||||
maxShareSize,
|
maxShareSize,
|
||||||
showCreateUploadModalCallback,
|
showCreateUploadModalCallback,
|
||||||
}: {
|
}: {
|
||||||
|
title?: string;
|
||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
maxShareSize: number;
|
maxShareSize: number;
|
||||||
showCreateUploadModalCallback: (files: FileUpload[]) => void;
|
showCreateUploadModalCallback: (files: FileUpload[]) => void;
|
||||||
@@ -78,7 +80,7 @@ const Dropzone = ({
|
|||||||
<TbCloudUpload size={50} />
|
<TbCloudUpload size={50} />
|
||||||
</Group>
|
</Group>
|
||||||
<Text align="center" weight={700} size="lg" mt="xl">
|
<Text align="center" weight={700} size="lg" mt="xl">
|
||||||
<FormattedMessage id="upload.dropzone.title" />
|
{title || <FormattedMessage id="upload.dropzone.title" />}
|
||||||
</Text>
|
</Text>
|
||||||
<Text align="center" size="sm" mt="xs" color="dimmed">
|
<Text align="center" size="sm" mt="xs" color="dimmed">
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|||||||
238
frontend/src/components/upload/EditableUpload.tsx
Normal file
238
frontend/src/components/upload/EditableUpload.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { Button, Group } from "@mantine/core";
|
||||||
|
import { useModals } from "@mantine/modals";
|
||||||
|
import { cleanNotifications } from "@mantine/notifications";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import pLimit from "p-limit";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
|
import Dropzone from "../../components/upload/Dropzone";
|
||||||
|
import FileList from "../../components/upload/FileList";
|
||||||
|
import showCompletedUploadModal from "../../components/upload/modals/showCompletedUploadModal";
|
||||||
|
import useConfig from "../../hooks/config.hook";
|
||||||
|
import useTranslate from "../../hooks/useTranslate.hook";
|
||||||
|
import shareService from "../../services/share.service";
|
||||||
|
import { FileListItem, FileMetaData, FileUpload } from "../../types/File.type";
|
||||||
|
import toast from "../../utils/toast.util";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
const promiseLimit = pLimit(3);
|
||||||
|
const chunkSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
let errorToastShown = false;
|
||||||
|
|
||||||
|
const EditableUpload = ({
|
||||||
|
maxShareSize,
|
||||||
|
shareId,
|
||||||
|
files: savedFiles = [],
|
||||||
|
}: {
|
||||||
|
maxShareSize?: number;
|
||||||
|
isReverseShare?: boolean;
|
||||||
|
shareId: string;
|
||||||
|
files?: FileMetaData[];
|
||||||
|
}) => {
|
||||||
|
const t = useTranslate();
|
||||||
|
const router = useRouter();
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
|
const [existingFiles, setExistingFiles] =
|
||||||
|
useState<Array<FileMetaData & { deleted?: boolean }>>(savedFiles);
|
||||||
|
const [uploadingFiles, setUploadingFiles] = useState<FileUpload[]>([]);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
const existingAndUploadedFiles: FileListItem[] = useMemo(
|
||||||
|
() => [...uploadingFiles, ...existingFiles],
|
||||||
|
[existingFiles, uploadingFiles],
|
||||||
|
);
|
||||||
|
const dirty = useMemo(() => {
|
||||||
|
return (
|
||||||
|
existingFiles.some((file) => !!file.deleted) || !!uploadingFiles.length
|
||||||
|
);
|
||||||
|
}, [existingFiles, uploadingFiles]);
|
||||||
|
|
||||||
|
const setFiles = (files: FileListItem[]) => {
|
||||||
|
const _uploadFiles = files.filter(
|
||||||
|
(file) => "uploadingProgress" in file,
|
||||||
|
) as FileUpload[];
|
||||||
|
const _existingFiles = files.filter(
|
||||||
|
(file) => !("uploadingProgress" in file),
|
||||||
|
) as FileMetaData[];
|
||||||
|
|
||||||
|
setUploadingFiles(_uploadFiles);
|
||||||
|
setExistingFiles(_existingFiles);
|
||||||
|
};
|
||||||
|
|
||||||
|
maxShareSize ??= parseInt(config.get("share.maxSize"));
|
||||||
|
|
||||||
|
const uploadFiles = async (files: FileUpload[]) => {
|
||||||
|
const fileUploadPromises = files.map(async (file, fileIndex) =>
|
||||||
|
// Limit the number of concurrent uploads to 3
|
||||||
|
promiseLimit(async () => {
|
||||||
|
let fileId: string;
|
||||||
|
|
||||||
|
const setFileProgress = (progress: number) => {
|
||||||
|
setUploadingFiles((files) =>
|
||||||
|
files.map((file, callbackIndex) => {
|
||||||
|
if (fileIndex == callbackIndex) {
|
||||||
|
file.uploadingProgress = progress;
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
setFileProgress(1);
|
||||||
|
|
||||||
|
let chunks = Math.ceil(file.size / chunkSize);
|
||||||
|
|
||||||
|
// If the file is 0 bytes, we still need to upload 1 chunk
|
||||||
|
if (chunks == 0) chunks++;
|
||||||
|
|
||||||
|
for (let chunkIndex = 0; chunkIndex < chunks; chunkIndex++) {
|
||||||
|
const from = chunkIndex * chunkSize;
|
||||||
|
const to = from + chunkSize;
|
||||||
|
const blob = file.slice(from, to);
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (event) =>
|
||||||
|
await shareService
|
||||||
|
.uploadFile(
|
||||||
|
shareId,
|
||||||
|
event,
|
||||||
|
{
|
||||||
|
id: fileId,
|
||||||
|
name: file.name,
|
||||||
|
},
|
||||||
|
chunkIndex,
|
||||||
|
chunks,
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
fileId = response.id;
|
||||||
|
resolve(response);
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
setFileProgress(((chunkIndex + 1) / chunks) * 100);
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof AxiosError &&
|
||||||
|
e.response?.data.error == "unexpected_chunk_index"
|
||||||
|
) {
|
||||||
|
// Retry with the expected chunk index
|
||||||
|
chunkIndex = e.response!.data!.expectedChunkIndex - 1;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
setFileProgress(-1);
|
||||||
|
// Retry after 5 seconds
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
chunkIndex = -1;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(fileUploadPromises);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFiles = async () => {
|
||||||
|
const removedFiles = existingFiles.filter((file) => !!file.deleted);
|
||||||
|
|
||||||
|
if (removedFiles.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
removedFiles.map(async (file) => {
|
||||||
|
await shareService.removeFile(shareId, file.id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
setExistingFiles(existingFiles.filter((file) => !file.deleted));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revertComplete = async () => {
|
||||||
|
await shareService.revertComplete(shareId).then();
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeShare = async () => {
|
||||||
|
return await shareService.completeShare(shareId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await revertComplete();
|
||||||
|
await uploadFiles(uploadingFiles);
|
||||||
|
|
||||||
|
const hasFailed = uploadingFiles.some(
|
||||||
|
(file) => file.uploadingProgress == -1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasFailed) {
|
||||||
|
await removeFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
await completeShare();
|
||||||
|
|
||||||
|
if (!hasFailed) {
|
||||||
|
toast.success(t("share.edit.notify.save-success"));
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("share.edit.notify.generic-error"));
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendFiles = (appendingFiles: FileUpload[]) => {
|
||||||
|
setUploadingFiles([...appendingFiles, ...uploadingFiles]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if there are any files that failed to upload
|
||||||
|
const fileErrorCount = uploadingFiles.filter(
|
||||||
|
(file) => file.uploadingProgress == -1,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (fileErrorCount > 0) {
|
||||||
|
if (!errorToastShown) {
|
||||||
|
toast.error(
|
||||||
|
t("upload.notify.count-failed", { count: fileErrorCount }),
|
||||||
|
{
|
||||||
|
withCloseButton: false,
|
||||||
|
autoClose: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
errorToastShown = true;
|
||||||
|
} else {
|
||||||
|
cleanNotifications();
|
||||||
|
errorToastShown = false;
|
||||||
|
}
|
||||||
|
}, [uploadingFiles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group position="right" mb={20}>
|
||||||
|
<Button loading={isUploading} disabled={!dirty} onClick={() => save()}>
|
||||||
|
<FormattedMessage id="common.button.save" />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Dropzone
|
||||||
|
title={t("share.edit.append-upload")}
|
||||||
|
maxShareSize={maxShareSize}
|
||||||
|
showCreateUploadModalCallback={appendFiles}
|
||||||
|
isUploading={isUploading}
|
||||||
|
/>
|
||||||
|
{existingAndUploadedFiles.length > 0 && (
|
||||||
|
<FileList files={existingAndUploadedFiles} setFiles={setFiles} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default EditableUpload;
|
||||||
@@ -1,41 +1,106 @@
|
|||||||
import { ActionIcon, Table } from "@mantine/core";
|
import { ActionIcon, Table } from "@mantine/core";
|
||||||
import { Dispatch, SetStateAction } from "react";
|
|
||||||
import { TbTrash } from "react-icons/tb";
|
import { TbTrash } from "react-icons/tb";
|
||||||
import { FileUpload } from "../../types/File.type";
|
import { GrUndo } from "react-icons/gr";
|
||||||
|
import { FileListItem } from "../../types/File.type";
|
||||||
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
import { byteToHumanSizeString } from "../../utils/fileSize.util";
|
||||||
import UploadProgressIndicator from "./UploadProgressIndicator";
|
import UploadProgressIndicator from "./UploadProgressIndicator";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
|
|
||||||
const FileList = ({
|
const FileListRow = ({
|
||||||
|
file,
|
||||||
|
onRemove,
|
||||||
|
onRestore,
|
||||||
|
}: {
|
||||||
|
file: FileListItem;
|
||||||
|
onRemove?: () => void;
|
||||||
|
onRestore?: () => void;
|
||||||
|
}) => {
|
||||||
|
{
|
||||||
|
const uploadable = "uploadingProgress" in file;
|
||||||
|
const uploading = uploadable && file.uploadingProgress !== 0;
|
||||||
|
const removable = uploadable
|
||||||
|
? file.uploadingProgress === 0
|
||||||
|
: onRemove && !file.deleted;
|
||||||
|
const restorable = onRestore && !uploadable && !!file.deleted; // maybe undefined, force boolean
|
||||||
|
const deleted = !uploadable && !!file.deleted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
color: deleted ? "rgba(120, 120, 120, 0.5)" : "inherit",
|
||||||
|
textDecoration: deleted ? "line-through" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td>{file.name}</td>
|
||||||
|
<td>{byteToHumanSizeString(+file.size)}</td>
|
||||||
|
<td>
|
||||||
|
{removable && (
|
||||||
|
<ActionIcon
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
size={25}
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<TbTrash />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
{uploading && (
|
||||||
|
<UploadProgressIndicator progress={file.uploadingProgress} />
|
||||||
|
)}
|
||||||
|
{restorable && (
|
||||||
|
<ActionIcon
|
||||||
|
color="primary"
|
||||||
|
variant="light"
|
||||||
|
size={25}
|
||||||
|
onClick={onRestore}
|
||||||
|
>
|
||||||
|
<GrUndo />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileList = <T extends FileListItem = FileListItem>({
|
||||||
files,
|
files,
|
||||||
setFiles,
|
setFiles,
|
||||||
}: {
|
}: {
|
||||||
files: FileUpload[];
|
files: T[];
|
||||||
setFiles: Dispatch<SetStateAction<FileUpload[]>>;
|
setFiles: (files: T[]) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const remove = (index: number) => {
|
const remove = (index: number) => {
|
||||||
files.splice(index, 1);
|
const file = files[index];
|
||||||
|
|
||||||
|
if ("uploadingProgress" in file) {
|
||||||
|
files.splice(index, 1);
|
||||||
|
} else {
|
||||||
|
files[index] = { ...file, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
setFiles([...files]);
|
setFiles([...files]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const restore = (index: number) => {
|
||||||
|
const file = files[index];
|
||||||
|
|
||||||
|
if ("uploadingProgress" in file) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
files[index] = { ...file, deleted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles([...files]);
|
||||||
|
};
|
||||||
|
|
||||||
const rows = files.map((file, i) => (
|
const rows = files.map((file, i) => (
|
||||||
<tr key={i}>
|
<FileListRow
|
||||||
<td>{file.name}</td>
|
key={i}
|
||||||
<td>{byteToHumanSizeString(file.size)}</td>
|
file={file}
|
||||||
<td>
|
onRemove={() => remove(i)}
|
||||||
{file.uploadingProgress == 0 ? (
|
onRestore={() => restore(i)}
|
||||||
<ActionIcon
|
/>
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
size={25}
|
|
||||||
onClick={() => remove(i)}
|
|
||||||
>
|
|
||||||
<TbTrash />
|
|
||||||
</ActionIcon>
|
|
||||||
) : (
|
|
||||||
<UploadProgressIndicator progress={file.uploadingProgress} />
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
));
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -358,6 +358,13 @@ export default {
|
|||||||
|
|
||||||
// END /share/[id]
|
// END /share/[id]
|
||||||
|
|
||||||
|
// /share/[id]/edit
|
||||||
|
"share.edit.title": "Edit {shareId}",
|
||||||
|
"share.edit.append-upload": "Append file",
|
||||||
|
"share.edit.notify.generic-error": "An error occurred while finishing your share.",
|
||||||
|
"share.edit.notify.save-success": "Share updated successfully",
|
||||||
|
// END /share/[id]/edit
|
||||||
|
|
||||||
// /admin/config
|
// /admin/config
|
||||||
"admin.config.title": "Configuration",
|
"admin.config.title": "Configuration",
|
||||||
"admin.config.category.general": "General",
|
"admin.config.category.general": "General",
|
||||||
|
|||||||
@@ -264,6 +264,12 @@ export default {
|
|||||||
"share.modal.file-preview.error.not-supported.title": "该文件类型不支持预览",
|
"share.modal.file-preview.error.not-supported.title": "该文件类型不支持预览",
|
||||||
"share.modal.file-preview.error.not-supported.description": "该文件类型不支持预览,请下载后打开查看",
|
"share.modal.file-preview.error.not-supported.description": "该文件类型不支持预览,请下载后打开查看",
|
||||||
// END /share/[id]
|
// END /share/[id]
|
||||||
|
// /share/[id]/edit
|
||||||
|
"share.edit.title": "编辑 {shareId}",
|
||||||
|
"share.edit.append-upload": "追加文件",
|
||||||
|
"share.edit.notify.generic-error": "保存共享的过程中发生了错误",
|
||||||
|
"share.edit.notify.save-success": "共享已更新成功",
|
||||||
|
// END /share/[id]/edit
|
||||||
// /admin/config
|
// /admin/config
|
||||||
"admin.config.title": "配置管理",
|
"admin.config.title": "配置管理",
|
||||||
"admin.config.category.general": "通用",
|
"admin.config.category.general": "通用",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useModals } from "@mantine/modals";
|
|||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { TbInfoCircle, TbLink, TbTrash } from "react-icons/tb";
|
import { TbEdit, TbInfoCircle, TbLink, TbTrash } from "react-icons/tb";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import Meta from "../../components/Meta";
|
import Meta from "../../components/Meta";
|
||||||
import showShareInformationsModal from "../../components/account/showShareInformationsModal";
|
import showShareInformationsModal from "../../components/account/showShareInformationsModal";
|
||||||
@@ -110,6 +110,11 @@ const MyShares = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Group position="right">
|
<Group position="right">
|
||||||
|
<Link href={`/share/${share.id}/edit`}>
|
||||||
|
<ActionIcon color="orange" variant="light" size={25}>
|
||||||
|
<TbEdit />
|
||||||
|
</ActionIcon>
|
||||||
|
</Link>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
color="blue"
|
color="blue"
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
65
frontend/src/pages/share/[shareId]/edit.tsx
Normal file
65
frontend/src/pages/share/[shareId]/edit.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { LoadingOverlay } from "@mantine/core";
|
||||||
|
import { useModals } from "@mantine/modals";
|
||||||
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import showErrorModal from "../../../components/share/showErrorModal";
|
||||||
|
import shareService from "../../../services/share.service";
|
||||||
|
import { Share as ShareType } from "../../../types/share.type";
|
||||||
|
import useTranslate from "../../../hooks/useTranslate.hook";
|
||||||
|
import EditableUpload from "../../../components/upload/EditableUpload";
|
||||||
|
import Meta from "../../../components/Meta";
|
||||||
|
|
||||||
|
export function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
|
return {
|
||||||
|
props: { shareId: context.params!.shareId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const Share = ({ shareId }: { shareId: string }) => {
|
||||||
|
const t = useTranslate();
|
||||||
|
const modals = useModals();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [share, setShare] = useState<ShareType>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
shareService
|
||||||
|
.getFromOwner(shareId)
|
||||||
|
.then((share) => {
|
||||||
|
setShare(share);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
const { error } = e.response.data;
|
||||||
|
if (e.response.status == 404) {
|
||||||
|
if (error == "share_removed") {
|
||||||
|
showErrorModal(
|
||||||
|
modals,
|
||||||
|
t("share.error.removed.title"),
|
||||||
|
e.response.data.message,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showErrorModal(
|
||||||
|
modals,
|
||||||
|
t("share.error.not-found.title"),
|
||||||
|
t("share.error.not-found.description"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showErrorModal(modals, t("common.error"), t("common.error.unknown"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingOverlay visible />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Meta title={t("share.edit.title", { shareId })} />
|
||||||
|
<EditableUpload shareId={shareId} files={share?.files || []} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Share;
|
||||||
@@ -202,7 +202,9 @@ const Upload = ({
|
|||||||
showCreateUploadModalCallback={showCreateUploadModalCallback}
|
showCreateUploadModalCallback={showCreateUploadModalCallback}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
/>
|
/>
|
||||||
{files.length > 0 && <FileList files={files} setFiles={setFiles} />}
|
{files.length > 0 && (
|
||||||
|
<FileList<FileUpload> files={files} setFiles={setFiles} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,10 +19,18 @@ const completeShare = async (id: string) => {
|
|||||||
return (await api.post(`shares/${id}/complete`)).data;
|
return (await api.post(`shares/${id}/complete`)).data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const revertComplete = async (id: string) => {
|
||||||
|
return (await api.delete(`shares/${id}/complete`)).data;
|
||||||
|
};
|
||||||
|
|
||||||
const get = async (id: string): Promise<Share> => {
|
const get = async (id: string): Promise<Share> => {
|
||||||
return (await api.get(`shares/${id}`)).data;
|
return (await api.get(`shares/${id}`)).data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFromOwner = async (id: string): Promise<Share> => {
|
||||||
|
return (await api.get(`shares/${id}/from-owner`)).data;
|
||||||
|
};
|
||||||
|
|
||||||
const getMetaData = async (id: string): Promise<ShareMetaData> => {
|
const getMetaData = async (id: string): Promise<ShareMetaData> => {
|
||||||
return (await api.get(`shares/${id}/metaData`)).data;
|
return (await api.get(`shares/${id}/metaData`)).data;
|
||||||
};
|
};
|
||||||
@@ -63,6 +71,10 @@ const downloadFile = async (shareId: string, fileId: string) => {
|
|||||||
window.location.href = `${window.location.origin}/api/shares/${shareId}/files/${fileId}`;
|
window.location.href = `${window.location.origin}/api/shares/${shareId}/files/${fileId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeFile = async (shareId: string, fileId: string) => {
|
||||||
|
await api.delete(`shares/${shareId}/files/${fileId}`);
|
||||||
|
};
|
||||||
|
|
||||||
const uploadFile = async (
|
const uploadFile = async (
|
||||||
shareId: string,
|
shareId: string,
|
||||||
readerEvent: ProgressEvent<FileReader>,
|
readerEvent: ProgressEvent<FileReader>,
|
||||||
@@ -121,14 +133,17 @@ const removeReverseShare = async (id: string) => {
|
|||||||
export default {
|
export default {
|
||||||
create,
|
create,
|
||||||
completeShare,
|
completeShare,
|
||||||
|
revertComplete,
|
||||||
getShareToken,
|
getShareToken,
|
||||||
get,
|
get,
|
||||||
|
getFromOwner,
|
||||||
remove,
|
remove,
|
||||||
getMetaData,
|
getMetaData,
|
||||||
doesFileSupportPreview,
|
doesFileSupportPreview,
|
||||||
getMyShares,
|
getMyShares,
|
||||||
isShareIdAvailable,
|
isShareIdAvailable,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
|
removeFile,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
setReverseShare,
|
setReverseShare,
|
||||||
createReverseShare,
|
createReverseShare,
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ export type FileMetaData = {
|
|||||||
name: string;
|
name: string;
|
||||||
size: string;
|
size: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FileListItem = FileUpload | (FileMetaData & { deleted?: boolean });
|
||||||
|
|||||||
Reference in New Issue
Block a user