feat(uploads): text based progress indicator & time remaining (#154)
* feat(uploads): text based progress indicator * fix(uploads): align other status icons with progress * feat(uploads): time remaing on progress indicator hover * fix(uploads): add callback function for more accurate progress * fix(uploads): cap progress text at 99 to prevent ui uglyness * feat(uploads): make upload progress customisable by admin * fix(uploads): revert status icons to original size * fix(translations): missing grammar on new string * feat(uploads): add circle with text an progress option * fix(translations): simplify new upload progress string
This commit is contained in:
@@ -66,6 +66,11 @@ export const configVariables = {
|
||||
defaultValue: "",
|
||||
secret: false,
|
||||
},
|
||||
uploadProgressStyle: {
|
||||
type: "string",
|
||||
defaultValue: "circle",
|
||||
secret: false,
|
||||
},
|
||||
},
|
||||
share: {
|
||||
allowRegistration: {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { stringToTimespan, timespanToString } from "../../../utils/date.util";
|
||||
import FileSizeInput from "../../core/FileSizeInput";
|
||||
import TimespanInput from "../../core/TimespanInput";
|
||||
import { LOCALES } from "../../../i18n/locales";
|
||||
import useTranslate from "../../../hooks/useTranslate.hook";
|
||||
|
||||
const AdminConfigInput = ({
|
||||
configVariable,
|
||||
@@ -37,6 +38,7 @@ const AdminConfigInput = ({
|
||||
updatedConfigVariables?: UpdateConfig[];
|
||||
optionalConfigVariables?: AdminConfig[];
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
const isCustomCssConfig = configVariable.key === "appearance.customCss";
|
||||
const isThemePrimaryColorConfig =
|
||||
configVariable.key === "appearance.themePrimaryColor";
|
||||
@@ -47,6 +49,8 @@ const AdminConfigInput = ({
|
||||
configVariable.key === "appearance.themeColorScheme";
|
||||
const isDefaultLanguageConfig =
|
||||
configVariable.key === "general.defaultLanguage";
|
||||
const isUploadProgressStyleConfig =
|
||||
configVariable.key === "appearance.uploadProgressStyle";
|
||||
const isEmailShareConfig =
|
||||
configVariable.key === "email.enableShareEmailRecipients";
|
||||
const isEmailVerificationConfig =
|
||||
@@ -246,6 +250,37 @@ const AdminConfigInput = ({
|
||||
value={form.values.stringValue}
|
||||
onChange={(value) => onValueChange(configVariable, value)}
|
||||
/>
|
||||
) : isUploadProgressStyleConfig ? (
|
||||
<Select
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
disabled={!configVariable.allowEdit}
|
||||
data={[
|
||||
{
|
||||
value: "circle",
|
||||
label: t(
|
||||
"admin.config.appearance.upload-progress-style.circle",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "circle-percentage",
|
||||
label: t(
|
||||
"admin.config.appearance.upload-progress-style.circle-percentage",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "percentage-time",
|
||||
label: t(
|
||||
"admin.config.appearance.upload-progress-style.percentage-time",
|
||||
),
|
||||
},
|
||||
]}
|
||||
value={form.values.stringValue}
|
||||
placeholder={configVariable.defaultValue}
|
||||
onChange={(value) => onValueChange(configVariable, value ?? "")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
style={{
|
||||
|
||||
@@ -25,9 +25,10 @@ const renderFileName = (name: string) => {
|
||||
};
|
||||
|
||||
const getFileNameOrPath = (file: FileListItem) => {
|
||||
const pathName = ("webkitRelativePath" in file && file.webkitRelativePath)
|
||||
? file.webkitRelativePath
|
||||
: file.name;
|
||||
const pathName =
|
||||
"webkitRelativePath" in file && file.webkitRelativePath
|
||||
? file.webkitRelativePath
|
||||
: file.name;
|
||||
return pathName.replace(/\\/g, "/").replace(/^\//, "");
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,145 @@
|
||||
import { Loader, RingProgress } from "@mantine/core";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Loader, RingProgress, Text } from "@mantine/core";
|
||||
import { TbCircleCheck } from "react-icons/tb";
|
||||
import { HoverTip } from "../core/HoverTip";
|
||||
import { useIntl } from "react-intl";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
const UploadProgressIndicator = ({ progress }: { progress: number }) => {
|
||||
if (progress > 0 && progress < 100) {
|
||||
return (
|
||||
<RingProgress
|
||||
sections={[{ value: progress, color: "victoria" }]}
|
||||
thickness={3}
|
||||
size={25}
|
||||
/>
|
||||
const intl = useIntl();
|
||||
const config = useConfig();
|
||||
const progressStyle =
|
||||
config.get("appearance.uploadProgressStyle") ?? "circle";
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress <= 0 || progress >= 100) {
|
||||
startTimeRef.current = null;
|
||||
setRemainingSeconds(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startTimeRef.current) {
|
||||
startTimeRef.current = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - startTimeRef.current;
|
||||
if (elapsedMs > 500) {
|
||||
const rate = progress / 100;
|
||||
const totalMs = elapsedMs / rate;
|
||||
const remainingMs = totalMs - elapsedMs;
|
||||
setRemainingSeconds(remainingMs / 1000);
|
||||
}
|
||||
}, [progress]);
|
||||
|
||||
const formatRemainingTime = (seconds: number | null): string => {
|
||||
if (seconds === null || !isFinite(seconds) || seconds < 0) {
|
||||
return intl.formatMessage({
|
||||
id: "upload.filelist.estimating",
|
||||
});
|
||||
}
|
||||
|
||||
let timeStr = "";
|
||||
if (seconds < 60) {
|
||||
timeStr = `${Math.round(seconds)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
if (minutes < 60) {
|
||||
timeStr = `${minutes}m ${remainingSeconds}s`;
|
||||
} else {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
timeStr = `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: "upload.filelist.remaining",
|
||||
},
|
||||
{ time: timeStr },
|
||||
);
|
||||
};
|
||||
|
||||
if (progress > 0 && progress < 100) {
|
||||
const tooltipLabel = formatRemainingTime(remainingSeconds);
|
||||
if (progressStyle === "circle") {
|
||||
return (
|
||||
<HoverTip label={tooltipLabel}>
|
||||
<RingProgress
|
||||
sections={[{ value: progress, color: "victoria" }]}
|
||||
thickness={3}
|
||||
size={25}
|
||||
/>
|
||||
</HoverTip>
|
||||
);
|
||||
} else if (progressStyle === "circle-percentage") {
|
||||
return (
|
||||
<HoverTip label={tooltipLabel}>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
}}
|
||||
>
|
||||
<RingProgress
|
||||
sections={[{ value: progress, color: "victoria" }]}
|
||||
thickness={3}
|
||||
size={40}
|
||||
label={
|
||||
<Text size="xs" color="victoria" weight={500} align="center">
|
||||
{Math.min(Math.round(progress), 99)}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</HoverTip>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Text size="sm" color="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{Math.min(Math.round(progress), 99)}% • {tooltipLabel}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
} else if (progress >= 100) {
|
||||
if (progressStyle === "circle-percentage") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
}}
|
||||
>
|
||||
<TbCircleCheck color="green" size={35} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <TbCircleCheck color="green" size={22} />;
|
||||
} else {
|
||||
if (progressStyle === "circle-percentage") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: 40,
|
||||
height: 40,
|
||||
}}
|
||||
>
|
||||
<Loader color="red" size={30} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Loader color="red" size={19} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -339,6 +339,8 @@ export default {
|
||||
// FileList.tsx
|
||||
"upload.filelist.name": "Name",
|
||||
"upload.filelist.size": "Size",
|
||||
"upload.filelist.estimating": "Estimating...",
|
||||
"upload.filelist.remaining": "{time} remaining",
|
||||
|
||||
// showCreateUploadModal.tsx
|
||||
"upload.modal.title": "Create Share",
|
||||
@@ -488,6 +490,12 @@ export default {
|
||||
"admin.config.appearance.custom-css": "Custom CSS",
|
||||
"admin.config.appearance.custom-css.description":
|
||||
"Global CSS applied to the frontend. Use carefully, as invalid CSS may affect the UI.",
|
||||
"admin.config.appearance.upload-progress-style": "Upload progress style",
|
||||
"admin.config.appearance.upload-progress-style.description":
|
||||
"Choose how upload progress is displayed in the file list.",
|
||||
"admin.config.appearance.upload-progress-style.circle": "Circle indicator",
|
||||
"admin.config.appearance.upload-progress-style.circle-percentage": "Circle with percentage",
|
||||
"admin.config.appearance.upload-progress-style.percentage-time": "Percentage and time remaining",
|
||||
"admin.config.general.app-url": "App URL",
|
||||
"admin.config.general.app-url.description":
|
||||
"On which URL Pingvin Share is available",
|
||||
|
||||
@@ -19,7 +19,10 @@ import { FileUpload } from "../../types/File.type";
|
||||
import { CreateShare, Share } from "../../types/share.type";
|
||||
import toast from "../../utils/toast.util";
|
||||
import { useRouter } from "next/router";
|
||||
import { getNormalizedFileName, filterDuplicateFiles } from "../../utils/file.util";
|
||||
import {
|
||||
getNormalizedFileName,
|
||||
filterDuplicateFiles,
|
||||
} from "../../utils/file.util";
|
||||
|
||||
const promiseLimit = pLimit(3);
|
||||
let errorToastShown = false;
|
||||
@@ -114,6 +117,19 @@ const Upload = ({
|
||||
},
|
||||
chunkIndex,
|
||||
chunks,
|
||||
(progressEvent) => {
|
||||
if (progressEvent.total && file.size > 0) {
|
||||
const chunkProgress =
|
||||
progressEvent.loaded / progressEvent.total;
|
||||
const uploadedBytesBeforeThisChunk =
|
||||
chunkIndex * chunkSize.current;
|
||||
const uploadedBytesInThisChunk = blob.size * chunkProgress;
|
||||
const totalUploaded =
|
||||
uploadedBytesBeforeThisChunk + uploadedBytesInThisChunk;
|
||||
const overallPercent = (totalUploaded / file.size) * 100;
|
||||
setFileProgress(Math.min(overallPercent, 99.9));
|
||||
}
|
||||
},
|
||||
)
|
||||
.then((response) => {
|
||||
fileId = response.id;
|
||||
@@ -169,10 +185,10 @@ const Upload = ({
|
||||
};
|
||||
|
||||
const handleDropzoneFilesChanged = (newFiles: FileUpload[]) => {
|
||||
const filtered = filterDuplicateFiles(
|
||||
newFiles,
|
||||
files,
|
||||
(normalizedName) => toast.error(t("upload.notify.duplicate-skipped", { name: normalizedName }))
|
||||
const filtered = filterDuplicateFiles(newFiles, files, (normalizedName) =>
|
||||
toast.error(
|
||||
t("upload.notify.duplicate-skipped", { name: normalizedName }),
|
||||
),
|
||||
);
|
||||
if (filtered.length === 0) return;
|
||||
|
||||
@@ -218,7 +234,10 @@ const Upload = ({
|
||||
const filtered = filterDuplicateFiles(
|
||||
[fileUpload],
|
||||
files,
|
||||
(normalizedName) => toast.error(t("upload.notify.duplicate-skipped", { name: normalizedName }))
|
||||
(normalizedName) =>
|
||||
toast.error(
|
||||
t("upload.notify.duplicate-skipped", { name: normalizedName }),
|
||||
),
|
||||
);
|
||||
if (filtered.length === 0) return;
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ const uploadFile = async (
|
||||
},
|
||||
chunkIndex: number,
|
||||
totalChunks: number,
|
||||
onUploadProgress?: (progressEvent: any) => void,
|
||||
): Promise<FileUploadResponse> => {
|
||||
if (!isValidId(shareId)) throw new Error("Invalid Share ID");
|
||||
return (
|
||||
@@ -143,6 +144,7 @@ const uploadFile = async (
|
||||
chunkIndex,
|
||||
totalChunks,
|
||||
},
|
||||
onUploadProgress,
|
||||
})
|
||||
).data;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user