Skip to content
Merged
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
2 changes: 2 additions & 0 deletions web/apps/photos/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"@hugeicons/core-free-icons": "^1.1.0",
"@hugeicons/react": "^1.1.4",
"chrono-node": "^2.9.0",
"debounce": "^2.2.0",
"ente-accounts": "*",
Expand Down
180 changes: 166 additions & 14 deletions web/apps/photos/src/components/DownloadStatusNotifications.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import {
Download01Icon,
Loading03Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import ReplayIcon from "@mui/icons-material/Replay";
import { keyframes, styled, Typography } from "@mui/material";
import { useBaseContext } from "ente-base/context";
import {
isSaveComplete,
Expand All @@ -8,6 +16,15 @@ import {
import { Notification } from "ente-new/photos/components/Notification";
import { t } from "i18next";

/** Maximum characters for album name before truncation */
const MAX_ALBUM_NAME_LENGTH = 25;

/** Truncate album name with ellipsis if it exceeds max length */
const truncateAlbumName = (name: string): string => {
if (name.length <= MAX_ALBUM_NAME_LENGTH) return name;
return name.slice(0, MAX_ALBUM_NAME_LENGTH) + "...";
};

interface DownloadStatusNotificationsProps {
/**
* A list of user-initiated downloads for which a status should be shown.
Expand Down Expand Up @@ -89,33 +106,116 @@ export const DownloadStatusNotifications: React.FC<

return saveGroups.map((group, index) => {
const hasErrors = isSaveCompleteWithErrors(group);
const isComplete = isSaveComplete(group);
const canRetry = hasErrors && !!group.retry;
const failedTitle = `${t("download_failed")} (${group.failed}/${group.total})`;

// Determine if this is a ZIP download (web with multiple files or live photo)
const isZipDownload = !group.downloadDirPath && group.total > 1;
const isDesktopOrSingleFile =
!!group.downloadDirPath || group.total === 1;

// Build the status text for the caption
let statusText: React.ReactNode;
if (hasErrors) {
// Show specific error message based on failure reason
if (group.failureReason === "network_offline") {
statusText = t("download_failed_network_offline");
} else if (group.failureReason === "file_error") {
statusText = t("download_failed_file_error");
} else {
statusText = t("download_failed");
}
} else if (isComplete) {
statusText = t("download_complete");
} else if (isZipDownload) {
const part = group.currentPart ?? 1;
statusText = group.isDownloadingZip
? t("downloading_part", { part })
: t("preparing_part", { part });
} else if (isDesktopOrSingleFile) {
statusText =
group.total === 1
? t("downloading_file")
: t("downloading_files");
} else {
statusText = t("downloading");
}

// Build caption: "Status • X / Y files"
const progress = t("download_progress", {
count: group.success + group.failed,
total: group.total,
});
const caption = (
<Typography
variant="small"
sx={{
color: hasErrors ? "white" : "text.muted",
fontVariantNumeric: "tabular-nums",
}}
>
{statusText}
{!isComplete && <> &bull; {progress}</>}
</Typography>
);

// Determine the start icon based on state
let startIcon: React.ReactNode;
if (hasErrors) {
startIcon = <ErrorOutlineIcon />;
} else if (isComplete) {
startIcon = (
<GlowingIconWrapper>
<HugeiconsIcon icon={Tick02Icon} size={28} />
</GlowingIconWrapper>
);
} else if (isZipDownload && !group.isDownloadingZip) {
// Preparing state - use loading icon
startIcon = <SpinningIcon />;
} else {
// Downloading state
startIcon = (
<DroppingIconWrapper>
<HugeiconsIcon icon={Download01Icon} size={28} />
</DroppingIconWrapper>
);
}

// Title is always the album name (truncated)
const truncatedName = truncateAlbumName(group.title);

return (
<Notification
key={group.id}
horizontal="left"
sx={{ "&&": { bottom: `${index * 80 + 20}px` } }}
sx={{
"&&": { bottom: `${index * 80 + 20}px` },
width: "min(400px, 100vw)",
borderRadius: "20px",
"& .MuiButton-root": {
borderRadius: "20px",
padding: "16px 16px 16px 20px",
},
"& .MuiIconButton-root": {
width: "40px",
height: "40px",
"& svg": { fontSize: "22px" },
},
}}
open={true}
onClose={createOnClose(group)}
keepOpenOnClick
attributes={{
color: hasErrors ? "critical" : "secondary",
title: hasErrors
? failedTitle
: isSaveComplete(group)
? t("download_complete")
: t("downloading_album", { name: group.title }),
caption: isSaveComplete(group)
? group.title
: t("download_progress", {
count: group.success + group.failed,
total: group.total,
}),
startIcon,
title: truncatedName,
caption,
onClick: createOnClick(group),
endIcon: canRetry ? (
<ReplayIcon titleAccess={t("retry")} />
<ReplayIcon
titleAccess={t("retry")}
sx={{ color: "white" }}
/>
) : undefined,
onEndIconClick: canRetry
? () => group.retry?.()
Expand All @@ -125,3 +225,55 @@ export const DownloadStatusNotifications: React.FC<
);
});
};

/** CSS keyframes for spinning animation */
const spinAnimation = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;

/** CSS keyframes for drop from top animation */
const dropAnimation = keyframes`
0% { transform: translateY(-100%); opacity: 0.15; }
50% { transform: translateY(10%); opacity: 0.6; }
100% { transform: translateY(0); opacity: 1; }
`;

/** CSS keyframes for green glow animation */
const glowAnimation = keyframes`
0% { color: var(--mui-palette-fixed-success); }
100% { color: inherit; }
`;

/** CSS keyframes for fade in animation */
const fadeInAnimation = keyframes`
0% { opacity: 0; }
100% { opacity: 1; }
`;

/** Drop animation icon wrapper */
const DroppingIconWrapper = styled("span")`
display: inline-flex;
animation: ${dropAnimation} 0.8s ease-out forwards;
`;

/** Glowing icon wrapper for success state */
const GlowingIconWrapper = styled("span")`
display: inline-flex;
animation: ${glowAnimation} 2s ease-out forwards;
`;

/** Spinning loading icon wrapper */
const SpinningIconWrapper = styled("span")`
display: inline-flex;
animation:
${fadeInAnimation} 0.5s ease-out forwards,
${spinAnimation} 3s linear infinite;
`;

/** Spinning loading icon */
const SpinningIcon: React.FC = () => (
<SpinningIconWrapper>
<HugeiconsIcon icon={Loading03Icon} size={28} />
</SpinningIconWrapper>
);
10 changes: 10 additions & 0 deletions web/packages/base/locales/en-US/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,17 @@
"unpreviewable_file_message": "This file could not be previewed",
"download_complete": "Download complete",
"downloading_album": "Downloading {{name}}",
"creating_zip": "Creating ZIP for {{name}}",
"preparing": "Preparing",
"downloading": "Downloading",
"preparing_part": "Preparing Part {{part}}",
"downloading_part": "Downloading Part {{part}}",
"downloading_file": "Downloading file",
"downloading_files": "Downloading files",
"download_failed": "Download failed",
"download_failed_network_offline": "Connection lost",
"download_failed_file_error": "Some files failed",
"retry": "Retry",
"download_progress": "{{count, number}} / {{total, number}} files",
"christmas": "Christmas",
"christmas_eve": "Christmas Eve",
Expand Down
20 changes: 20 additions & 0 deletions web/packages/gallery/components/utils/save-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@ export interface SaveGroup {
* An {@link AbortController} that can be used to cancel the save.
*/
canceller: AbortController;
/**
* The reason for the failure, if any.
*
* This is used to show a more specific error message to the user.
* - "network_offline": The network went offline during download
* - "file_error": One or more individual files failed to download
* - undefined: No specific reason (generic error)
*/
failureReason?: "network_offline" | "file_error";
/**
* `true` when the ZIP file is being downloaded from memory to the user's
* device. This is only relevant for web downloads where files are first
* collected into a ZIP in memory, then saved to the device.
*/
isDownloadingZip?: boolean;
/**
* The current ZIP part number being processed. Only relevant for web
* downloads where files are batched into multiple ZIP parts.
*/
currentPart?: number;
}

/**
Expand Down
1 change: 1 addition & 0 deletions web/packages/gallery/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"ente-utils": "*",
"exifreader": "^4.32.0",
"hls-video-element": "^1.5.8",
"jszip": "^3.10.1",
"leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "^0.1.2",
"localforage": "^1.10.0",
Expand Down
Loading