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:
smp46
2026-07-24 19:51:54 +02:00
committed by GitHub
parent 57704e5943
commit 27414e6d97
7 changed files with 215 additions and 17 deletions
+5
View File
@@ -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={{
+4 -3
View File
@@ -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} />;
}
};
+8
View File
@@ -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",
+25 -6
View File
@@ -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;
+2
View File
@@ -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;
};