Skip to content
This repository was archived by the owner on Jun 29, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 94 additions & 16 deletions frontend/src/components/upload/FileList.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { ActionIcon, Table } from "@mantine/core";
import {
ActionIcon,
Badge,
Box,
Group,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { TbTrash } from "react-icons/tb";
import { GrUndo } from "react-icons/gr";
import { FileListItem } from "../../types/File.type";
import { FileListItem, FileUpload } from "../../types/File.type";
import { byteToHumanSizeString } from "../../utils/fileSize.util";
import UploadProgressIndicator from "./UploadProgressIndicator";
import { FormattedMessage } from "react-intl";
import useTranslate from "../../hooks/useTranslate.hook";
import { formatTimeRemaining } from "../../utils/time.util";

const FileListRow = ({
file,
Expand All @@ -15,6 +25,7 @@ const FileListRow = ({
onRemove?: () => void;
onRestore?: () => void;
}) => {
const t = useTranslate();
{
const uploadable = "uploadingProgress" in file;
const uploading = uploadable && file.uploadingProgress !== 0;
Expand All @@ -33,6 +44,17 @@ const FileListRow = ({
>
<td>{file.name}</td>
<td>{byteToHumanSizeString(+file.size)}</td>
<td>
{uploading &&
uploadable &&
file.estimatedTimeRemaining !== undefined && (
<Tooltip label={t("upload.filelist.time-remaining")}>
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this tooltip really necessary? The column has already a label.

<Text size="xs" color="dimmed" mr={8}>
{formatTimeRemaining(file.estimatedTimeRemaining)}
</Text>
</Tooltip>
)}
</td>
<td>
{removable && (
<ActionIcon
Expand Down Expand Up @@ -103,21 +125,77 @@ const FileList = <T extends FileListItem = FileListItem>({
/>
));

// Calculate upload statistics
const uploadableFiles = files.filter(
(file) => "uploadingProgress" in file,
) as unknown as FileUpload[];

const inProgressFiles = uploadableFiles.filter(
(file) => file.uploadingProgress > 0 && file.uploadingProgress < 100,
).length;

const pendingFiles = uploadableFiles.filter(
(file) => file.uploadingProgress === 0,
).length;

const remainingFiles = inProgressFiles + pendingFiles;

// Calculate overall estimated time
let maxEstimatedTime = 0;
if (inProgressFiles > 0) {
for (const file of uploadableFiles) {
if (
file.estimatedTimeRemaining &&
file.estimatedTimeRemaining > maxEstimatedTime
) {
maxEstimatedTime = file.estimatedTimeRemaining;
}
}
}

return (
<Table>
<thead>
<tr>
<th>
<FormattedMessage id="upload.filelist.name" />
</th>
<th>
<FormattedMessage id="upload.filelist.size" />
</th>
<th></th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
<>
{uploadableFiles.length > 0 && remainingFiles > 0 && (
<Box mb={16}>
<Group position="apart">
<Text size="sm">
<FormattedMessage
id="upload.filelist.remaining-files"
values={{
count: remainingFiles,
total: uploadableFiles.length,
}}
/>
</Text>
{maxEstimatedTime > 0 && (
<Badge color="blue" variant="light">
<FormattedMessage
id="upload.filelist.estimated-time"
values={{ time: formatTimeRemaining(maxEstimatedTime) }}
/>
</Badge>
)}
</Group>
</Box>
)}
<Table>
<thead>
<tr>
<th>
<FormattedMessage id="upload.filelist.name" />
</th>
<th>
<FormattedMessage id="upload.filelist.size" />
</th>
<th>
<FormattedMessage id="upload.filelist.time" />
</th>
<th></th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
</>
);
};

Expand Down
9 changes: 8 additions & 1 deletion frontend/src/components/upload/UploadProgressIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Loader, RingProgress } from "@mantine/core";
import { TbCircleCheck } from "react-icons/tb";
const UploadProgressIndicator = ({ progress }: { progress: number }) => {

type UploadProgressIndicatorProps = {
progress: number;
};

const UploadProgressIndicator = ({
progress,
}: UploadProgressIndicatorProps) => {
if (progress > 0 && progress < 100) {
return (
<RingProgress
Expand Down
45 changes: 30 additions & 15 deletions frontend/src/i18n/translations/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@ export default {
// FileList.tsx
"upload.filelist.name": "Name",
"upload.filelist.size": "Size",
"upload.filelist.time": "Time remaining",
"upload.filelist.time-remaining": "Estimated time remaining",
"upload.filelist.remaining-files": "{count} of {total} files remaining",
"upload.filelist.estimated-time": "About {time} left",

// showCreateUploadModal.tsx
"upload.modal.title": "Create Share",
Expand Down Expand Up @@ -408,14 +412,15 @@ export default {
// /imprint
"imprint.title": "Imprint",
// END /imprint

// /privacy
"privacy.title": "Privacy Policy",
// END /privacy

// /admin/config
"admin.config.config-file-warning.title": "Configuration file present",
"admin.config.config-file-warning.description": "As you have a configured Pingvin Share with a configuration file, you can't change the configuration through the UI.",
"admin.config.config-file-warning.description":
"As you have a configured Pingvin Share with a configuration file, you can't change the configuration through the UI.",

"admin.config.title": "Configuration",
"admin.config.category.general": "General",
Expand Down Expand Up @@ -579,7 +584,7 @@ export default {
"Discovery URI of the OpenID Connect OAuth app",
"admin.config.oauth.oidc-sign-out": "Sign out from OpenID Connect",
"admin.config.oauth.oidc-sign-out.description":
"Whether the Sign out button will sign out from the OpenID Connect provider",
"Whether the 'Sign out' button will sign out from the OpenID Connect provider",
"admin.config.oauth.oidc-scope": "OpenID Connect scope",
"admin.config.oauth.oidc-scope.description":
"Scopes which should be requested from the OpenID Connect provider.",
Expand All @@ -594,12 +599,12 @@ export default {
"admin.config.oauth.oidc-role-general-access":
"OpenID Connect role for general access",
"admin.config.oauth.oidc-role-general-access.description":
"Role required for general access. Must be present in a users roles for them to log in. " +
"Role required for general access. Must be present in a user's roles for them to log in. " +
"Leave it blank if you don't know what this config is.",
"admin.config.oauth.oidc-role-admin-access":
"OpenID Connect role for admin access",
"admin.config.oauth.oidc-role-admin-access.description":
"Role required for administrative access. Must be present in a users roles for them to access the admin panel. " +
"Role required for administrative access. Must be present in a user's roles for them to access the admin panel. " +
"Leave it blank if you don't know what this config is.",
"admin.config.oauth.oidc-client-id": "OpenID Connect Client ID",
"admin.config.oauth.oidc-client-id.description":
Expand Down Expand Up @@ -642,33 +647,43 @@ export default {

"admin.config.category.s3": "S3",
"admin.config.s3.enabled": "Enabled",
"admin.config.s3.enabled.description": "Whether S3 should be used to store the shared files instead of the local file system.",
"admin.config.s3.enabled.description":
"Whether S3 should be used to store the shared files instead of the local file system.",
"admin.config.s3.endpoint": "Endpoint",
"admin.config.s3.endpoint.description": "The URL of the S3 bucket.",
"admin.config.s3.region": "Region",
"admin.config.s3.region.description": "The region of the S3 bucket.",
"admin.config.s3.bucket-name": "Bucket name",
"admin.config.s3.bucket-name.description": "The name of the S3 bucket.",
"admin.config.s3.bucket-path": "Path",
"admin.config.s3.bucket-path.description": "The default path which should be used to store the files in the S3 bucket.",
"admin.config.s3.bucket-path.description":
"The default path which should be used to store the files in the S3 bucket.",
"admin.config.s3.key": "Key",
"admin.config.s3.key.description": "The key which allows you to access the S3 bucket.",
"admin.config.s3.key.description":
"The key which allows you to access the S3 bucket.",
"admin.config.s3.secret": "Secret",
"admin.config.s3.secret.description": "The secret which allows you to access the S3 bucket.",
"admin.config.s3.secret.description":
"The secret which allows you to access the S3 bucket.",
"admin.config.s3.use-checksum": "Use checksum",
"admin.config.s3.use-checksum.description": "Turn off for backends that do not support checksum (e.g. B2).",
"admin.config.s3.use-checksum.description":
"Turn off for backends that do not support checksum (e.g. B2).",

"admin.config.category.legal": "Legal",
"admin.config.legal.enabled": "Enable legal notices",
"admin.config.legal.enabled.description": "Whether to show a link to imprint and privacy policy in the footer.",
"admin.config.legal.enabled.description":
"Whether to show a link to imprint and privacy policy in the footer.",
"admin.config.legal.imprint-text": "Imprint text",
"admin.config.legal.imprint-text.description": "The text which should be shown in the imprint. Supports Markdown. Leave blank to link to an external imprint page.",
"admin.config.legal.imprint-text.description":
"The text which should be shown in the imprint. Supports Markdown. Leave blank to link to an external imprint page.",
"admin.config.legal.imprint-url": "Imprint URL",
"admin.config.legal.imprint-url.description": "If you already have an imprint page you can link it here instead of using the text field.",
"admin.config.legal.imprint-url.description":
"If you already have an imprint page you can link it here instead of using the text field.",
"admin.config.legal.privacy-policy-text": "Privacy policy text",
"admin.config.legal.privacy-policy-text.description": "The text which should be shown in the privacy policy. Supports Markdown. Leave blank to link to an external privacy policy page.",
"admin.config.legal.privacy-policy-text.description":
"The text which should be shown in the privacy policy. Supports Markdown. Leave blank to link to an external privacy policy page.",
"admin.config.legal.privacy-policy-url": "Privacy policy URL",
"admin.config.legal.privacy-policy-url.description": "If you already have a privacy policy page you can link it here instead of using the text field.",
"admin.config.legal.privacy-policy-url.description":
"If you already have a privacy policy page you can link it here instead of using the text field.",

// 404
"404.description": "Oops this page doesn't exist.",
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/pages/upload/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,21 @@ const Upload = ({
setFiles((files) =>
files.map((file, callbackIndex) => {
if (fileIndex == callbackIndex) {
if (progress === 1 && !file.uploadStartTime) {
file.uploadStartTime = Date.now();
}

file.uploadingProgress = progress;

if (progress > 0 && progress < 100) {
const elapsedMs =
Date.now() - (file.uploadStartTime || Date.now());
const estimatedTotalMs = (elapsedMs / progress) * 100;
const remainingMs = estimatedTotalMs - elapsedMs;
file.estimatedTimeRemaining = Math.max(0, remainingMs);
} else if (progress >= 100) {
file.estimatedTimeRemaining = 0;
}
}
return file;
}),
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/types/File.type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export type FileUpload = File & { uploadingProgress: number };
export type FileUpload = File & {
uploadingProgress: number;
uploadStartTime?: number;
estimatedTimeRemaining?: number;
};

export type FileUploadResponse = { id: string; name: string };

Expand Down
20 changes: 20 additions & 0 deletions frontend/src/utils/time.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Formats a time in milliseconds to a human-readable string
* @param ms Time in milliseconds
* @returns Formatted time string (e.g. "5h 30m" or "2m 15s")
*/
export const formatTimeRemaining = (ms: number): string => {
if (ms <= 0) return "0s";

const seconds = Math.floor(ms / 1000) % 60;
const minutes = Math.floor(ms / (1000 * 60)) % 60;
const hours = Math.floor(ms / (1000 * 60 * 60));

if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
};