diff --git a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay.tsx b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay.tsx index 1699245d33..7a38a86793 100644 --- a/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay.tsx +++ b/web/apps/photos/src/components/PhotoViewer/ImageEditorOverlay.tsx @@ -999,7 +999,7 @@ const FreehandCropRegion = forwardRef( key={index} sx={{ border: "1px solid", - borderColor: "fixed.white", + borderColor: "white", boxSizing: "border-box", pointerEvents: "none", }} @@ -1011,9 +1011,9 @@ const FreehandCropRegion = forwardRef( position: "absolute", height: "10px", width: "10px", - backgroundColor: "fixed.white", + backgroundColor: "white", border: "1px solid", - borderColor: "fixed.black", + borderColor: "black", right: "-5px", bottom: "-5px", cursor: "se-resize", diff --git a/web/apps/photos/src/components/Sidebar/Preferences.tsx b/web/apps/photos/src/components/Sidebar/Preferences.tsx index 9be4d0e2a0..a90747d0c8 100644 --- a/web/apps/photos/src/components/Sidebar/Preferences.tsx +++ b/web/apps/photos/src/components/Sidebar/Preferences.tsx @@ -114,8 +114,14 @@ const LanguageSelector = () => { const updateCurrentLocale = (newLocale: SupportedLocale) => { setLocaleInUse(newLocale); + // [Note: Changing locale causes a full reload] + // // A full reload is needed because we use the global `t` instance // instead of the useTranslation hook. + // + // We also rely on this behaviour by caching various formatters in + // module static variables that not get updated if the i18n.language + // changes unless there is a full reload. window.location.reload(); }; diff --git a/web/apps/photos/src/components/Sidebar/SubscriptionCard.tsx b/web/apps/photos/src/components/Sidebar/SubscriptionCard.tsx index 4219a3134a..071a0e798b 100644 --- a/web/apps/photos/src/components/Sidebar/SubscriptionCard.tsx +++ b/web/apps/photos/src/components/Sidebar/SubscriptionCard.tsx @@ -48,7 +48,7 @@ export const SubscriptionCard: React.FC = ({ sx={{ borderRadius: "8px" }} /> ) : ( - + diff --git a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx b/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx index ff3aeb0240..4ca9f665cc 100644 --- a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx +++ b/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx @@ -1,23 +1,22 @@ import { Overlay } from "@/base/components/containers"; +import { formattedDateRelative } from "@/base/i18n-date"; import log from "@/base/log"; import { downloadManager } from "@/gallery/services/download"; +import { enteFileDeletionDate } from "@/media/file"; import { FileType } from "@/media/file-type"; -import { - GAP_BTW_TILES, - IMAGE_CONTAINER_MAX_WIDTH, -} from "@/new/photos/components/PhotoList"; +import { GAP_BTW_TILES } from "@/new/photos/components/PhotoList"; import { LoadingThumbnail, StaticThumbnail, } from "@/new/photos/components/PlaceholderThumbnails"; +import { TileBottomTextOverlay } from "@/new/photos/components/Tiles"; import { TRASH_SECTION } from "@/new/photos/services/collection"; import useLongPress from "@ente/shared/hooks/useLongPress"; import AlbumOutlinedIcon from "@mui/icons-material/AlbumOutlined"; import FavoriteRoundedIcon from "@mui/icons-material/FavoriteRounded"; import PlayCircleOutlineOutlinedIcon from "@mui/icons-material/PlayCircleOutlineOutlined"; -import { styled } from "@mui/material"; +import { styled, Typography } from "@mui/material"; import type { DisplayFile } from "components/PhotoFrame"; -import i18n from "i18next"; import { GalleryContext } from "pages/gallery"; import React, { useContext, useEffect, useRef, useState } from "react"; import { shouldShowAvatar } from "utils/file"; @@ -82,7 +81,7 @@ const Check = styled("input")<{ $active: boolean }>( content: ""; background-color: ${theme.vars.palette.accent.main}; border-color: ${theme.vars.palette.accent.main}; - color: ${theme.vars.palette.fixed.white}; + color: white; } /* checkmark foreground (tick) */ &:checked::after { @@ -112,21 +111,45 @@ const HoverOverlay = styled("div")<{ checked: boolean }>` "background:linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0))"}; `; +/** + * An overlay showing the avatars of the person who shared the item, at the top + * right. + */ const AvatarOverlay = styled(Overlay)` display: flex; justify-content: flex-end; align-items: flex-start; - padding-right: 5px; - padding-top: 5px; + padding: 5px; `; -const FavOverlay = styled(Overlay)` +/** + * An overlay showing the favorite icon at bottom left. + */ +const FavoriteOverlay = styled(Overlay)` display: flex; justify-content: flex-start; align-items: flex-end; - padding-left: 5px; - padding-bottom: 5px; - opacity: 0.9; + padding: 5px; + color: white; + opacity: 0.6; +`; + +/** + * An overlay with a gradient, showing the file type indicator (e.g. live photo, + * video) at the bottom right. + */ +const FileTypeIndicatorOverlay = styled(Overlay)` + display: flex; + justify-content: flex-end; + align-items: flex-end; + padding: 5px; + color: white; + background: linear-gradient( + 315deg, + rgba(0 0 0 / 0.14) 0%, + rgba(0 0 0 / 0.05) 30%, + transparent 50% + ); `; const InSelectRangeOverlay = styled(Overlay)( @@ -137,32 +160,6 @@ const InSelectRangeOverlay = styled(Overlay)( `, ); -const FileAndCollectionNameOverlay = styled("div")( - ({ theme }) => ` - width: 100%; - bottom: 0; - left: 0; - max-height: 40%; - width: 100%; - background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 2)); - & > p { - max-width: calc(${IMAGE_CONTAINER_MAX_WIDTH}px - 10px); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - margin: 2px; - text-align: center; - } - padding: 7px; - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; - color: ${theme.vars.palette.fixed.white}; - position: absolute; -`, -); - const SelectedOverlay = styled(Overlay)( ({ theme }) => ` z-index: 5; @@ -171,23 +168,6 @@ const SelectedOverlay = styled(Overlay)( `, ); -const FileTypeIndicatorOverlay = styled(Overlay)(({ theme }) => ({ - display: "flex", - justifyContent: "flex-end", - alignItems: "flex-end", - padding: "8px", - // TODO(LM): Ditto the dark one until lm is ready. - // background: - // "linear-gradient(315deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, - // 255, 0.05) 29.61%, rgba(255, 255, 255, 0) 49.86%)", - background: - "linear-gradient(315deg, rgba(0, 0, 0, 0.14) 0%, rgba(0, 0, 0, 0.05) 29.61%, rgba(0, 0, 0, 0) 49.86%)", - ...theme.applyStyles("dark", { - background: - "linear-gradient(315deg, rgba(0, 0, 0, 0.14) 0%, rgba(0, 0, 0, 0.05) 29.61%, rgba(0, 0, 0, 0) 49.86%)", - }), -})); - const Cont = styled("div")<{ disabled: boolean }>` display: flex; width: fit-content; @@ -241,6 +221,7 @@ export default function PreviewCard(props: IProps) { onRangeSelect, isRangeSelectActive, isInsSelectRange, + isFav, } = props; const [imgSrc, setImgSrc] = useState(file.msrc); @@ -345,10 +326,10 @@ export default function PreviewCard(props: IProps) { )} - {props.isFav && ( - + {isFav && ( + - + )} -

{formatDateRelative(file.deleteBy / 1000)}

- + + + {formattedDateRelative(enteFileDeletionDate(file))} + + )} ); } - -function formatDateRelative(date: number) { - const units = { - year: 24 * 60 * 60 * 1000 * 365, - month: (24 * 60 * 60 * 1000 * 365) / 12, - day: 24 * 60 * 60 * 1000, - hour: 60 * 60 * 1000, - minute: 60 * 1000, - second: 1000, - }; - const relativeDateFormat = new Intl.RelativeTimeFormat(i18n.language, { - localeMatcher: "best fit", - numeric: "always", - style: "long", - }); - const elapsed = date - Date.now(); // "Math.abs" accounts for both "past" & "future" scenarios - - for (const u in units) - if (Math.abs(elapsed) > units[u] || u === "second") - return relativeDateFormat.format( - Math.round(elapsed / units[u]), - u as Intl.RelativeTimeFormatUnit, - ); -} diff --git a/web/packages/base/components/EnteSwitch.tsx b/web/packages/base/components/EnteSwitch.tsx index 63a4afff20..6cd3b75b00 100644 --- a/web/packages/base/components/EnteSwitch.tsx +++ b/web/packages/base/components/EnteSwitch.tsx @@ -16,7 +16,7 @@ export const EnteSwitch: React.FC = styled((props) => ( transitionDuration: "300ms", "&.Mui-checked": { transform: "translateX(16px)", - color: theme.vars.palette.fixed.white, + color: "white", "& + .MuiSwitch-track": { opacity: 1, border: 0, diff --git a/web/packages/base/components/containers.tsx b/web/packages/base/components/containers.tsx index 2c4a17694f..d3aaa0558c 100644 --- a/web/packages/base/components/containers.tsx +++ b/web/packages/base/components/containers.tsx @@ -46,8 +46,16 @@ export const CenteredFill = styled("div")` `; /** - * An absolute positioned div that fills the entire nearest relatively - * positioned ancestor. + * An empty overlay on top of the nearest relative positioned ancestor. + * + * {@link Overlay} is an an absolute positioned div that fills the entire + * nearest relatively positioned ancestor. It is usually used in tandem with a + * derivate of {@link BaseTile} or {@link BaseTileButton} to show various + * indicators on top of thumbnails; but it can be used in any context where we + * want to overlay (usually) transparent content on top of a component. + * + * For filling much larger areas (e.g. showing a translucent overlay on top of + * the entire screen), use the MUI {@link Backdrop} instead. */ export const Overlay = styled("div")` position: absolute; diff --git a/web/packages/base/components/utils/mui-theme.d.ts b/web/packages/base/components/utils/mui-theme.d.ts index ae28747335..849c8e2569 100644 --- a/web/packages/base/components/utils/mui-theme.d.ts +++ b/web/packages/base/components/utils/mui-theme.d.ts @@ -120,8 +120,6 @@ declare module "@mui/material/styles" { * These do not change with the color scheme. */ fixed: { - white: string; - black: string; /** * Various fixed shades of gray. * TODO(LM) - audit and rename. diff --git a/web/packages/base/components/utils/theme.ts b/web/packages/base/components/utils/theme.ts index 7c8017f22c..c51303f2cf 100644 --- a/web/packages/base/components/utils/theme.ts +++ b/web/packages/base/components/utils/theme.ts @@ -333,8 +333,10 @@ const getColorSchemes = (colors: ReturnType) => ({ FilledInput: { bg: colors.light.fill.faint, hoverBg: colors.light.fill.faintHover, - // We don't use this currently. - // disabledBg: colors.light.fill.fainter, + // While we don't specifically have disabled inputs, TextInputs + // do get disabled when the form is submitting, and this value + // comes into play then. + disabledBg: colors.light.fill.fainter, }, }, }, @@ -396,7 +398,7 @@ const getColorSchemes = (colors: ReturnType) => ({ FilledInput: { bg: colors.dark.fill.faint, hoverBg: colors.dark.fill.faintHover, - // disabledBg: colors.dark.fill.faint, + disabledBg: colors.dark.fill.fainter, }, }, }, diff --git a/web/packages/base/date.ts b/web/packages/base/date.ts new file mode 100644 index 0000000000..f08084f79a --- /dev/null +++ b/web/packages/base/date.ts @@ -0,0 +1,14 @@ +/** + * Convert an epoch microsecond value to a JavaScript date. + * + * This is a convenience API for dealing with optional epoch microseconds in + * various data structures. Remote talks in terms of epoch microseconds, but + * JavaScript dates are underlain by epoch milliseconds, and this does a + * conversion, with a convenience of short circuiting undefined values. + */ +export const dateFromEpochMicroseconds = ( + epochMicroseconds: number | undefined, +) => + epochMicroseconds === undefined + ? undefined + : new Date(epochMicroseconds / 1000); diff --git a/web/packages/base/i18n-date.ts b/web/packages/base/i18n-date.ts new file mode 100644 index 0000000000..1c60e56d1e --- /dev/null +++ b/web/packages/base/i18n-date.ts @@ -0,0 +1,38 @@ +/** + * @file Various date formatters. + * + * Note that we rely on the current behaviour of a full reload on changing the + * language. See: [Note: Changing locale causes a full reload]. + */ +import i18n from "i18next"; + +let _relativeTimeFormat: Intl.RelativeTimeFormat | undefined; + +export const formattedDateRelative = (date: Date) => { + const units: [Intl.RelativeTimeFormatUnit, number][] = [ + ["year", 24 * 60 * 60 * 1000 * 365], + ["month", (24 * 60 * 60 * 1000 * 365) / 12], + ["day", 24 * 60 * 60 * 1000], + ["hour", 60 * 60 * 1000], + ["minute", 60 * 1000], + ["second", 1000], + ]; + + // Math.abs accounts for both past and future scenarios. + const elapsed = Math.abs(date.getTime() - Date.now()); + + // Lazily created, then cached, instance of RelativeTimeFormat. + const relativeTimeFormat = (_relativeTimeFormat ??= + new Intl.RelativeTimeFormat(i18n.language, { + localeMatcher: "best fit", + numeric: "always", + style: "short", + })); + + for (const [u, d] of units) { + if (elapsed > d) + return relativeTimeFormat.format(Math.round(elapsed / d), u); + } + + return relativeTimeFormat.format(Math.round(elapsed / 1000), "second"); +}; diff --git a/web/packages/base/i18n.ts b/web/packages/base/i18n.ts index 2786854cde..80407383ec 100644 --- a/web/packages/base/i18n.ts +++ b/web/packages/base/i18n.ts @@ -228,7 +228,15 @@ export const setLocaleInUse = async (locale: SupportedLocale) => { return i18n.changeLanguage(locale); }; -const numberFormatter = new Intl.NumberFormat(i18n.language); +let _numberFormat: Intl.NumberFormat | undefined; +/** + * Lazily created, cached, instance of NumberFormat used by + * {@link formattedNumber}. + * + * See: [Note: Changing locale causes a full reload]. + */ +const numberFormat = () => + (_numberFormat ??= new Intl.NumberFormat(i18n.language)); /** * Return the given {@link value} formatted for the current language and locale. @@ -238,7 +246,7 @@ const numberFormatter = new Intl.NumberFormat(i18n.language); * However, in some rare cases, we need to format a standalone number. For such * scenarios, this function can be used. */ -export const formattedNumber = (value: number) => numberFormatter.format(value); +export const formattedNumber = (value: number) => numberFormat().format(value); /** * A no-op marker for strings that, for various reasons, pending addition to the diff --git a/web/packages/media/file.ts b/web/packages/media/file.ts index 142e5a632f..767c86f5ef 100644 --- a/web/packages/media/file.ts +++ b/web/packages/media/file.ts @@ -1,4 +1,5 @@ import { sharedCryptoWorker } from "@/base/crypto"; +import { dateFromEpochMicroseconds } from "@/base/date"; import log from "@/base/log"; import { type Metadata, ItemVisibility } from "./file-metadata"; @@ -172,7 +173,15 @@ export interface EnteFile * But its presence is not guaranteed. */ pubMagicMetadata?: FilePublicMagicMetadata; + /** + * `true` if this file is in trash (i.e. it has been deleted by the user, + * and will be permanently deleted after 30 days of being moved to trash). + */ isTrashed?: boolean; + /** + * If this is a file in trash, then {@link deleteBy} contains the epoch + * microseconds when this file will be permanently deleted. + */ deleteBy?: number; /** * The base64 representation of the decrypted encryption key associated with @@ -285,6 +294,16 @@ export const fileLogID = (file: EnteFile) => // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition `file ${file.metadata.title ?? "-"} (${file.id})`; +/** + * Return the date when the file will be deleted permanently. Only valid for + * files that are in the user's trash. + * + * This is a convenience wrapper over the {@link deleteBy} property of a file, + * converting that epoch microsecond value into a JavaScript date. + */ +export const enteFileDeletionDate = (file: EnteFile) => + dateFromEpochMicroseconds(file.deleteBy); + export async function decryptFile( file: EncryptedEnteFile, collectionKey: string, diff --git a/web/packages/new/photos/components/Tiles.tsx b/web/packages/new/photos/components/Tiles.tsx index fedf6a03c3..4a054230c8 100644 --- a/web/packages/new/photos/components/Tiles.tsx +++ b/web/packages/new/photos/components/Tiles.tsx @@ -1,3 +1,4 @@ +import { Overlay } from "@/base/components/containers"; import { downloadManager } from "@/gallery/services/download"; import { type EnteFile } from "@/media/file"; import { @@ -99,12 +100,12 @@ export const ItemCard: React.FC> = ({ * A generic "base" tile, meant to be used (after setting dimensions) as the * {@link TileComponent} provided to an {@link ItemCard}. * - * Use {@link ItemTileOverlay} (usually via one of its presets) to overlay - * content on top of the tile. + * Use {@link Overlay} (usually via one of its presets) to overlay content on + * top of the tile. */ const BaseTile = styled("div")` display: flex; - /* Act as container for the absolutely positioned ItemTileOverlays. */ + /* Act as container for the absolutely positioned 'Overlay's. */ position: relative; border-radius: 4px; overflow: hidden; @@ -130,13 +131,11 @@ export const PreviewItemTile = styled(BaseTile)` /** * A rectangular, TV-ish tile used in the gallery bar. */ -export const BarItemTile = styled(BaseTile)( - ({ theme }) => ` +export const BarItemTile = styled(BaseTile)` width: 90px; height: 64px; - color: ${theme.vars.palette.fixed.white}; -`, -); + color: white; +`; /** * A square tile used on the duplicates listing. @@ -158,7 +157,7 @@ export const BaseTileButton = styled(UnstyledButton)` /* Rest of this is mostly verbatim from BaseTile ... */ display: flex; - /* Act as container for the absolutely positioned ItemTileOverlays. */ + /* Act as container for the absolutely positioned 'Overlay's. */ position: relative; border-radius: 4px; overflow: hidden; @@ -180,24 +179,10 @@ export const LargeTileButton = styled(BaseTileButton)` `; /** - * An empty overlay on top of the nearest relative positioned ancestor. - * - * This is meant to be used in tandem with a derivate of {@link BaseTile} or - * {@link BaseTileButton}. + * An {@link Overlay} suitable for hosting textual content at the top left of + * small and medium sized tiles. */ -export const ItemTileOverlay = styled("div")` - position: absolute; - width: 100%; - height: 100%; - top: 0; - left: 0; -`; - -/** - * An {@link ItemTileOverlay} suitable for hosting textual content at the top - * left of small and medium sized tiles. - */ -export const TileTextOverlay = styled(ItemTileOverlay)` +export const TileTextOverlay = styled(Overlay)` padding: 4px; background: linear-gradient( 0deg, @@ -210,7 +195,7 @@ export const TileTextOverlay = styled(ItemTileOverlay)` * A variation of {@link TileTextOverlay} for use with larger tiles like the * {@link CollectionTile}. */ -export const LargeTileTextOverlay = styled(ItemTileOverlay)` +export const LargeTileTextOverlay = styled(Overlay)` padding: 8px; background: linear-gradient( 0deg, @@ -222,7 +207,7 @@ export const LargeTileTextOverlay = styled(ItemTileOverlay)` /** * A container for "+", suitable for use with a {@link LargeTileTextOverlay}. */ -export const LargeTilePlusOverlay = styled(ItemTileOverlay)( +export const LargeTilePlusOverlay = styled(Overlay)( ({ theme }) => ` display: flex; justify-content: center; @@ -233,13 +218,15 @@ export const LargeTilePlusOverlay = styled(ItemTileOverlay)( ); /** - * An {@link ItemTileOverlay} suitable for holding the collection name shown - * atop the tiles in the duplicates listing. + * An {@link Overlay} suitable for showing text at the bottom center of the + * tile. Used by the tiles in trash (for showing the days until deletion) and + * duplicate listing (for showing the collection name). */ -export const DuplicateTileTextOverlay = styled(ItemTileOverlay)` +export const TileBottomTextOverlay = styled(Overlay)` display: flex; justify-content: center; align-items: flex-end; - padding: 4px; - background: linear-gradient(transparent 50%, rgba(0, 0, 0, 0.7)); + padding: 6px; + background: linear-gradient(transparent 30%, 80%, rgba(0 0 0 / 0.7)); + color: white; `; diff --git a/web/packages/new/photos/pages/duplicates.tsx b/web/packages/new/photos/pages/duplicates.tsx index 09b44b41a9..c939dcfdef 100644 --- a/web/packages/new/photos/pages/duplicates.tsx +++ b/web/packages/new/photos/pages/duplicates.tsx @@ -42,8 +42,8 @@ import { } from "react-window"; import { DuplicateItemTile, - DuplicateTileTextOverlay, ItemCard, + TileBottomTextOverlay, } from "../components/Tiles"; import { computeThumbnailGridLayoutParams, @@ -600,11 +600,11 @@ const ListItem: React.FC> = TileComponent={DuplicateItemTile} coverFile={item.file} > - - + + {item.collectionName} - + ))} diff --git a/web/packages/shared/time/format.ts b/web/packages/shared/time/format.ts index 9caa5daab9..4a728cad4d 100644 --- a/web/packages/shared/time/format.ts +++ b/web/packages/shared/time/format.ts @@ -1,5 +1,7 @@ import i18n, { t } from "i18next"; +// TODO: Move to @/base/date + const dateTimeFullFormatter1 = new Intl.DateTimeFormat(i18n.language, { weekday: "short", month: "short",