+ {children}
+ div>
+ );
+}
+
+function useSelection
(): SelectionContextProps {
+ const context = React.useContext>(SelectionContext);
+ if (context === undefined) {
+ throw new Error('useSelection must be used within a SelectionProvider');
+ }
+
+ return context;
+}
+
+export { SelectionProvider, Selectable, useSelection };
diff --git a/src/SelectionToolbar.tsx b/src/SelectionToolbar.tsx
new file mode 100644
index 0000000..280f686
--- /dev/null
+++ b/src/SelectionToolbar.tsx
@@ -0,0 +1,44 @@
+import { FC } from "react";
+import { useParams } from "react-router-dom";
+
+import BottomNavigation from "@mui/material/BottomNavigation";
+import BottomNavigationAction from "@mui/material/BottomNavigationAction";
+import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
+import DeselectIcon from '@mui/icons-material/Deselect';
+import Divider from "@mui/material/Divider";
+import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove';
+import SelectAllIcon from '@mui/icons-material/SelectAll';
+
+import { PhotoImageType } from "./types";
+import { useDialog } from "./dialogs";
+import { useSelection } from "./Selection";
+
+const SelectionToolbar: FC = () => {
+ const { collection = "", album = "" } = useParams();
+ const { get, all, cancel, isSelecting } = useSelection();
+ const dialog = useDialog();
+
+ const handleMove = () => {
+ dialog.move(collection, album, get());
+ }
+ const handleDelete = () => {
+ dialog.delete(collection, album, get());
+ }
+
+ if(!isSelecting)
+ return null;
+
+ return (
+
+
+ } />
+ } />
+
+ } />
+ } />
+
+
+ );
+}
+
+export default SelectionToolbar;
diff --git a/src/Thumb.tsx b/src/Thumb.tsx
index 78d3339..ba173c8 100644
--- a/src/Thumb.tsx
+++ b/src/Thumb.tsx
@@ -17,6 +17,7 @@ import { IconLivePhoto } from '@tabler/icons-react';
import { RenderPhotoProps } from "react-photo-album";
import BoxBar from "./BoxBar";
+import { Selectable } from "./Selection";
import { PhotoImageType } from "./types";
import useFavorite from "./favoriteHook";
import { useDialog } from "./dialogs";
@@ -27,6 +28,14 @@ const boxStyle: SxProps = {
backgroundColor: "action.hover",
cursor: "pointer",
};
+
+const selectedStyle: SxProps = {
+ outline: "5px solid dodgerblue",
+ outlineOffset: "-5px",
+ // border: "5px solid dodgerblue",
+ // boxSizing: "border-box",
+};
+
const iconsStyle: CSSProperties = {
WebkitFilter: "drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.8))",
filter: "drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.8))",
@@ -45,6 +54,7 @@ export default (photos: PhotoImageType[], showIcons: boolean) => ({ photo, layou
const { collection } = useParams();
const dialog = useDialog();
const [mouseOver, setMouseOver] = useState(false);
+ const [selected, setSelected] = useState(false);
const favorite = useFavorite();
const selectedFavorite = favorite.get();
@@ -126,14 +136,17 @@ export default (photos: PhotoImageType[], showIcons: boolean) => ({ photo, layou
>);
return (
-
- {renderDefaultPhoto({ wrapped: true })}
- {showIcons && icons}
-
+
+
+ {renderDefaultPhoto({ wrapped: true })}
+ {showIcons && icons}
+
+
);
}
diff --git a/src/Toolbar.tsx b/src/Toolbar.tsx
index b2f4efd..b6ee386 100644
--- a/src/Toolbar.tsx
+++ b/src/Toolbar.tsx
@@ -1,12 +1,14 @@
import React, {FC, useContext, useState, forwardRef } from "react";
-import { useTheme, styled } from '@mui/material/styles';
+import { useParams } from 'react-router-dom';
import { useDispatch } from 'react-redux';
+import { useTheme, styled } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import AddAlbumIcon from '@mui/icons-material/AddPhotoAlternate';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import ArrowRightIcon from '@mui/icons-material/ArrowRight';
import Box from "@mui/material/Box";
+import DifferenceIcon from '@mui/icons-material/Difference';
import Divider from "@mui/material/Divider";
import FavoriteMenu from './FavoriteMenu';
import IconButton from "@mui/material/IconButton";
@@ -20,6 +22,7 @@ import Tooltip from "@mui/material/Tooltip";
import ZoomInIcon from "@mui/icons-material/ZoomInRounded";
import ZoomOutIcon from "@mui/icons-material/ZoomOutRounded";
+import { Upload } from "./Upload";
import { useDialog } from './dialogs';
import { increaseZoom, decreaseZoom } from "./services/app";
@@ -123,9 +126,13 @@ export const ToolbarProvider: FC = ({ children }) => {
}
const ToolbarMenu : FC = () => {
+ const { collection, album} = useParams();
const dispatch = useDispatch();
const dialog = useDialog();
+ // Do not add buttons that require an album to be opened
+ const inAlbum = (collection && album);
+
const zoomIn = () => {
dispatch(increaseZoom());
}
@@ -135,6 +142,18 @@ const ToolbarMenu : FC = () => {
return (
+ {inAlbum && [
+ (),
+ ( dialog.duplicates(collection, album)}
+ icon={}
+ title="Duplicates"
+ tooltip="Find duplicated photos in this album"
+ aria-label="duplicates" />),
+ (),
+ ]}
+
dialog.newAlbum()}
icon={}
@@ -156,7 +175,6 @@ const ToolbarMenu : FC = () => {
aria-label="zoom out"
onClick={zoomOut}
icon={} />
-
);
}
diff --git a/src/Upload.tsx b/src/Upload.tsx
new file mode 100644
index 0000000..6b66116
--- /dev/null
+++ b/src/Upload.tsx
@@ -0,0 +1,257 @@
+
+import { FC, useState, useRef } from 'react';
+import { useParams } from 'react-router-dom';
+import { useDispatch } from 'react-redux';
+
+import Uploady, {
+ useUploady,
+ BatchItem,
+ FILE_STATES,
+ useItemAbortListener,
+ useItemCancelListener,
+ useItemErrorListener,
+ useItemFinalizeListener,
+ useItemFinishListener,
+ useItemProgressListener,
+ useItemStartListener,
+ useBatchFinalizeListener,
+ useBatchAddListener,
+ useAbortAll,
+} from '@rpldy/uploady';
+import UploadPreview, {
+ PreviewComponentProps,
+ PreviewItem,
+ PreviewMethods
+} from "@rpldy/upload-preview";
+import UploadDropZone from "@rpldy/upload-drop-zone";
+
+import { Divider, styled } from '@mui/material';
+import AddToPhotosIcon from '@mui/icons-material/AddToPhotos';
+import Avatar from '@mui/material/Avatar';
+import Box from '@mui/material/Box';
+import CircularProgress from '@mui/material/CircularProgress';
+import ClearAllIcon from '@mui/icons-material/ClearAll';
+import DangerousIcon from '@mui/icons-material/Dangerous';
+import DoneIcon from '@mui/icons-material/Done';
+import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
+import FileUploadIcon from '@mui/icons-material/FileUpload';
+import ImageNotSupportedIcon from '@mui/icons-material/ImageNotSupported';
+import ListItem from '@mui/material/ListItem';
+import ListItemAvatar from '@mui/material/ListItemAvatar';
+import ListItemIcon from '@mui/material/ListItemIcon';
+import ListItemText from '@mui/material/ListItemText';
+import Menu from '@mui/material/Menu';
+import MenuItem from '@mui/material/MenuItem';
+
+import api from './services/api';
+import { ToolbarItem } from './Toolbar';
+
+const StyledUploadDropZone = styled(UploadDropZone)({
+ "&.drag-over": {
+ backgroundColor: "rgba(128,128,128,0.6)",
+ }
+});
+
+const StyledCircularProgress = styled(CircularProgress)({
+ position: 'absolute',
+ top: -2,
+ left: -2,
+ zIndex: 1,
+});
+
+const StyledIcon = styled("div")({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ zIndex: 1,
+ padding: "8px",
+ filter: "drop-shadow(0px 0px 1px white)",
+});
+
+const UploadEntry = ({ type, url, id, name, size }: PreviewComponentProps) => {
+ const [ item, setItem ] = useState({} as BatchItem);
+
+ const st = item.state;
+ const isPending = (st === FILE_STATES.PENDING || st === FILE_STATES.ADDED || st === undefined);
+ const isUploading = (st === FILE_STATES.UPLOADING);
+ const isDone = (st === FILE_STATES.FINISHED);
+ const isError = (st === FILE_STATES.ABORTED || st === FILE_STATES.CANCELLED || st === FILE_STATES.ERROR);
+
+ // Catch all item updates
+ useItemAbortListener((item) => setItem(item), id);
+ useItemCancelListener((item) => setItem(item), id);
+ useItemErrorListener((item) => setItem(item), id);
+ useItemFinalizeListener((item) => setItem(item), id);
+ useItemFinishListener((item) => setItem(item), id);
+ useItemProgressListener((item) => setItem(item), id);
+ useItemStartListener((item) => setItem(item), id);
+
+ return (
+
+
+
+
+
+
+ {isPending && }
+ {isUploading && }
+ {isDone && }
+ {isError && }
+
+
+
+
+ );
+}
+
+
+interface UploadMenuProps {
+ open: boolean;
+ anchorEl: Element | null;
+ onOpen: () => void;
+ onClose: () => void;
+}
+
+const UploadMenu: FC = ({ open, anchorEl, onOpen, onClose }) => {
+ const { collection, album } = useParams();
+ const dispatch = useDispatch();
+ const uploady = useUploady();
+ const abortAll = useAbortAll();
+ const previewMethodsRef = useRef(null);
+ const [inProgress, setInProgress] = useState(false);
+ const [isEmpty, setIsEmpty] = useState(false);
+
+ const handleUpload = () => {
+ uploady.showFileUpload();
+ };
+
+ // Open menu when new uploads are added
+ useBatchAddListener(() => {
+ onOpen();
+ setInProgress(true);
+ });
+ // Reload album after uploading
+ useBatchFinalizeListener(() => {
+ dispatch(api.util.invalidateTags([{ type: 'Album', id: `${collection}:${album}` }]));
+ setInProgress(false);
+ });
+
+ const onAbortOrClear = () => {
+ if(inProgress)
+ abortAll();
+ else
+ previewMethodsRef.current?.clear();
+ };
+
+ const onPreviewsChanged = (items: PreviewItem[]) => {
+ setIsEmpty(items.length === 0);
+ };
+
+ return (
+
+ );
+}
+
+interface UploadProviderProps {
+ children?: JSX.Element | JSX.Element[];
+}
+
+export const UploadProvider: FC = ({children}) => {
+ const { collection, album } = useParams();
+
+ const uploadUrl = (!collection || !album) ? undefined :
+ `/api/collections/${collection}/albums/${album}/photos`;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const Upload: FC = () => {
+ const { collection, album } = useParams();
+ const dropdownRef = useRef(null);
+ const [open, setOpen] = useState(false);
+
+ const handleOpenMenu = () => {
+ setOpen(true);
+ };
+
+ const handleCloseMenu = () => {
+ setOpen(false);
+ };
+
+
+ // Do not add the upload button when not in a album
+ if(!collection || !album)
+ return null;
+
+ return (<>
+ }
+ onClick={handleOpenMenu}
+ title="Upload"
+ aria-label="upload photos"
+ tooltip="Upload photos to this album"
+ subMenu
+ showTitle
+ />
+
+
+ >);
+}
diff --git a/src/dialogs/Delete.tsx b/src/dialogs/Delete.tsx
new file mode 100644
index 0000000..3b01ba3
--- /dev/null
+++ b/src/dialogs/Delete.tsx
@@ -0,0 +1,110 @@
+import { FC, useState } from 'react';
+
+import Button from '@mui/material/Button';
+import CloseIcon from '@mui/icons-material/Close';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogContentText from '@mui/material/DialogContentText';
+import DialogTitle from '@mui/material/DialogTitle';
+import IconButton from '@mui/material/IconButton';
+import FormControl from '@mui/material/FormControl';
+import TextField from '@mui/material/TextField';
+
+import PhotoAlbum from 'react-photo-album';
+
+import { QueryDeletePhotos, useDeletePhotosMutation } from '../services/api';
+import useNotification from '../Notification';
+import { PhotoImageType } from '../types';
+
+interface DialogProps {
+ open: boolean;
+ collection: string;
+ album: string;
+ photos: PhotoImageType[];
+ onClose: () => void;
+}
+
+/* Dialog for delete action */
+const DeleteDialog: FC = ({collection, album, photos, open, onClose}) => {
+ const [deletePhotos] = useDeletePhotosMutation();
+ const [answer, setAnswer] = useState();
+ const [processingAction, setProcessingAction] = useState(false);
+
+ const { successNotification, errorNotification } = useNotification();
+
+ const answerOk = (answer?.toLocaleLowerCase() === "yes");
+
+ const handleAnswer = (event: React.ChangeEvent) => {
+ setAnswer(event.target.value)
+ }
+ const handleClose = () => {
+ setAnswer("");
+ onClose();
+ }
+
+ const handleDelete = async () => {
+ const query: QueryDeletePhotos = {
+ collection,
+ album,
+ target: {
+ photos: photos.map(photo => photo.id),
+ }
+ };
+
+ setProcessingAction(true);
+ try {
+ await deletePhotos(query).unwrap();
+ successNotification(`${photos.length} photos were deleted`);
+ handleClose();
+ }
+ catch(error) {
+ errorNotification("An error occured while deleting photos!");
+ console.log(error);
+ }
+ setProcessingAction(false);
+ }
+
+ return (
+
+ )
+}
+
+export default DeleteDialog;
diff --git a/src/dialogs/DeleteAlbum.tsx b/src/dialogs/DeleteAlbum.tsx
new file mode 100644
index 0000000..d90fd6b
--- /dev/null
+++ b/src/dialogs/DeleteAlbum.tsx
@@ -0,0 +1,58 @@
+import { FC } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogTitle from '@mui/material/DialogTitle';
+import Typography from '@mui/material/Typography';
+
+import useNotification from '../Notification';
+import { useDeleteAlbumMutation } from '../services/api';
+
+interface DialogProps {
+ open: boolean;
+ collection: string;
+ album: string;
+ onClose: () => void;
+}
+
+const DeleteAlbumDialog: FC = ({collection, album, open, onClose}) => {
+ const navigate = useNavigate();
+ const [deleteAlbum] = useDeleteAlbumMutation();
+ const { successNotification, errorNotification } = useNotification();
+
+ const handleClose = () => {
+ onClose();
+ }
+
+ const handleDeleteAlbum = async () => {
+ try {
+ await deleteAlbum({ collection, album }).unwrap();
+ successNotification(`Album ${album} successfully deleted`);
+ handleClose();
+ navigate("/" + collection);
+ }
+ catch (error: any) {
+ errorNotification(`Error deleting album: ${error.data.message}!`);
+ console.log(error);
+ }
+ }
+
+ return (
+
+ );
+}
+
+export default DeleteAlbumDialog;
diff --git a/src/dialogs/Duplicates.tsx b/src/dialogs/Duplicates.tsx
new file mode 100644
index 0000000..28da3f7
--- /dev/null
+++ b/src/dialogs/Duplicates.tsx
@@ -0,0 +1,452 @@
+import { FC, Fragment, useEffect, useState } from 'react';
+import { useTheme, SxProps, Theme } from "@mui/material/styles";
+import useMediaQuery from '@mui/material/useMediaQuery';
+
+import Alert from '@mui/material/Alert';
+import AlertTitle from '@mui/material/AlertTitle';
+import Avatar from '@mui/material/Avatar';
+import Badge from '@mui/material/Badge';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import Checkbox from '@mui/material/Checkbox';
+import Chip from '@mui/material/Chip';
+import CloseIcon from '@mui/icons-material/Close';
+import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogContentText from '@mui/material/DialogContentText';
+import DialogTitle from '@mui/material/DialogTitle';
+import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove';
+import Divider from '@mui/material/Divider';
+import FavoriteIcon from '@mui/icons-material/Favorite';
+import FileCopyIcon from '@mui/icons-material/FileCopy';
+import FormControl from '@mui/material/FormControl';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import FormGroup from '@mui/material/FormGroup';
+import IconButton from '@mui/material/IconButton';
+import InputLabel from '@mui/material/InputLabel';
+import LinearProgress from '@mui/material/LinearProgress';
+import Link from '@mui/material/Link';
+import List from '@mui/material/List';
+import ListItem from '@mui/material/ListItem';
+import ListItemAvatar from '@mui/material/ListItemAvatar';
+import ListItemText from '@mui/material/ListItemText';
+import MenuItem from '@mui/material/MenuItem';
+import NewReleasesIcon from '@mui/icons-material/NewReleases';
+import OutlinedInput from '@mui/material/OutlinedInput';
+import PinDropIcon from '@mui/icons-material/PinDrop';
+import RuleIcon from '@mui/icons-material/Rule';
+import SaveIcon from '@mui/icons-material/Save';
+import Select, { SelectChangeEvent } from '@mui/material/Select';
+import Tab from '@mui/material/Tab';
+import Tabs from '@mui/material/Tabs';
+import Tooltip from '@mui/material/Tooltip';
+import WarningIcon from '@mui/icons-material/Warning';
+
+import { useDialog } from '.';
+import { Duplicate, ResponseDuplicates, useDuplicatedPhotosQuery } from '../services/api';
+import { SelectionProvider, Selectable, useSelection } from '../Selection';
+import { PhotoImageType, PhotoType, PseudoAlbumType, urls } from '../types';
+import useFavorite from '../favoriteHook';
+
+const selectedStyle: SxProps = {
+ outline: "5px solid dodgerblue",
+ outlineOffset: "-5px",
+};
+
+interface ItemProps {
+ item: T;
+}
+const ItemDuplicated: FC> = ({item: {photo, found}}) => {
+ const dialog = useDialog();
+
+ const handleOpenPhoto = () => {
+ dialog.lightbox([photo, ...found.map(i => i.photo)], 0);
+ }
+
+ return (<>
+
+
+
+ (
+
+ {(partial || incomplete || conflict || samealbum) &&
+
+ {partial && Partial: found photo does not have all files}
+ {incomplete && Incomplete: the photo is missing some files, found entry is more complete}
+ {conflict && Conflict: the photo and the found entry both have missing files}
+ {samealbum && Same Album: duplicated in the same album
Do not delete all matches!}
+ }>
+
+
+ }
+ {collection}: {album} - {id}
+
+
+ {files.map((f, i) =>
+ {f.from < 0 ? <>∅> : photo.files[f.from].id} ↔ {f.to < 0 ? <>∅> : foundFiles[f.to].id}
+ )}
+ )
+ )}
+ />
+ >);
+}
+const ItemUnique: FC> = ({item}) => {
+ const dialog = useDialog();
+
+ const handleOpenPhoto = () => {
+ dialog.lightbox([item], 0);
+ }
+
+ return (<>
+
+
+
+ {item.files.map((f, i) => {f.id}
)}>}
+ />
+ >);
+}
+
+interface SelectableItemProps {
+ item: T;
+ component: FC>;
+}
+function SelectableItem({ item, component }: SelectableItemProps) {
+ const [selected, setSelected] = useState(false);
+ const Item = component;
+
+ const handleSelect = (state: boolean) => {
+ setSelected(state);
+ }
+
+ return (
+ item={item} onChange={handleSelect}>
+
+
+
+
+
+ );
+}
+
+interface ListItemsProps {
+ items: T[];
+ component: FC>;
+}
+function ListItems({ items, component }: ListItemsProps) {
+ const { all, cancel } = useSelection();
+
+ if(items.length < 1)
+ return No items to display;
+
+ return (<>
+
+
+ {items.map((item, index) => ())}
+
+
+ Select All -
+ Clear Selection
+
+ >);
+}
+
+interface TabPanelProps {
+ children?: React.ReactNode;
+ index: number;
+ value: number;
+}
+function TabPanel(props: TabPanelProps) {
+ const { children, value, index, ...other } = props;
+
+ return (
+
+ {value === index && children}
+
+ );
+}
+
+interface DialogProps {
+ open: boolean;
+ collection: string;
+ album: string;
+ onClose: () => void;
+}
+
+const defaultData: ResponseDuplicates = {
+ total: 0,
+ albums: [],
+ keep: [],
+ unique: [],
+ delete: [],
+ conflict: [],
+ samealbum: [],
+ countKeep: 0,
+ countDelete: 0,
+ countUnique: 0,
+ countConflict: 0,
+ countSameAlbum: 0
+}
+
+function filterList(list: Duplicate[], filter: PseudoAlbumType[], onlyMultiple: boolean): Duplicate[] {
+ const validList = list || [];
+
+ const filteredAlbums = filter.length < 1 ? validList :
+ validList.map(d => ({
+ ...d,
+ found: d.found.filter(p => filter.some(f => f.collection === p.photo.collection && f.album === p.photo.album))
+ })).filter(d => d.found.length > 0);
+
+ return onlyMultiple ?
+ filteredAlbums.filter(d => d.found.length > 1) :
+ filteredAlbums;
+}
+
+const DuplicatesDialog: FC = ({open, collection, album, onClose}) => {
+ const theme = useTheme();
+ const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
+ const dialog = useDialog();
+ const favorite = useFavorite();
+ const { data = defaultData, isFetching } = useDuplicatedPhotosQuery({ collection, album }, {skip: !open});
+ const [albumsFilter, setAlbumsFilter] = useState([]);
+ const [onlyMultiple, setOnlyMultiple] = useState(false);
+ const [tab, setTab] = useState(0);
+ const [isSelectingDups, setSelectingDups] = useState(false);
+ const [isSelectingUniq, setSelectingUniq] = useState(false);
+ const [selDups, setSelDups] = useState([]);
+ const [selUniq, setSelUniq] = useState([]);
+
+ const noSelection = isFetching ||
+ // Delete, Keep, Conflict, Same
+ ((tab === 0 || tab === 1 || tab === 2 || tab === 3) && !isSelectingDups) ||
+ // Unique
+ (tab === 4 && !isSelectingUniq);
+
+ // Filter by selected albums
+ const filter = albumsFilter.map(album => JSON.parse(album) as PseudoAlbumType);
+ const listDelete = filterList(data.delete, filter, onlyMultiple);
+ const listKeep = filterList(data.keep, filter, onlyMultiple);
+ const listConflict = filterList(data.conflict, filter, onlyMultiple);
+ const listSameAlbum = filterList(data.samealbum, filter, onlyMultiple);
+ const listUnique = data.unique || [];
+
+ // Clear album filter when opening
+ useEffect(() => setAlbumsFilter([]), [open]);
+
+ const handleChangeAlbumsFilter = (event: SelectChangeEvent) => {
+ const val = event.target.value;
+ // On autofill we get a stringified value.
+ setAlbumsFilter(typeof val === "string" ? [val] : val);
+ };
+
+ const handleOnlyMultiple = (event: React.ChangeEvent) => {
+ setOnlyMultiple(event.target.checked);
+ };
+
+ const handleClose = () => {
+ onClose();
+ };
+
+ const handleFavorite = async () => {
+ const isSelectingDupsBak = isSelectingDups;
+ const isSelectingUniqBak = isSelectingUniq;
+ setSelectingDups(false);
+ setSelectingUniq(false);
+
+ const grouped = new Map>>();
+
+ if(tab === 0 || tab === 1 || tab === 2 || tab === 3) {
+ selDups.forEach(duplicate => {
+ duplicate.found.forEach(({photo}) => {
+ // Collection
+ const collection = grouped.get(photo.collection) || new Map>();
+ if (!grouped.has(photo.collection))
+ grouped.set(photo.collection, collection);
+ // Album
+ const album = collection.get(photo.album) || new Set();
+ if (!collection.has(photo.album))
+ collection.set(photo.album, album);
+ // Photo
+ album.add(photo.id);
+ });
+ });
+ } else if(tab === 4) {
+ selUniq.forEach(entry => {
+ // Collection
+ const collection = grouped.get(entry.collection) || new Map>();
+ if (!grouped.has(entry.collection)) {
+ grouped.set(entry.collection, collection);
+ }
+ // Album
+ const album = collection.get(entry.album) || new Set();
+ if (!collection.has(entry.album)) {
+ collection.set(entry.album, album);
+ }
+ // Photo
+ album.add(entry.id);
+ })
+ }
+
+ const promises: Promise[] = [];
+ grouped.forEach((collectionMap, collection) => {
+ collectionMap.forEach(async (albumSet, album) => {
+ promises.push(favorite.save({ collection, album, photos: Array.from(albumSet) }, [], true));
+ });
+ });
+ await Promise.all(promises);
+
+ setSelectingDups(isSelectingDupsBak);
+ setSelectingUniq(isSelectingUniqBak);
+ };
+
+ const handleMove = () => {
+ const selection =
+ tab === 0 || tab === 1 || tab === 2 || tab === 3 ? selDups.map(item => item.photo) :
+ tab === 4 ? selUniq : [];
+ // Create urls for thumbnails
+ const photos: PhotoImageType[] = selection.map(photo => ({ ...photo, src: urls.thumb(photo) }));
+ // Show move dialog
+ dialog.move(collection, album, photos);
+ onClose();
+ };
+
+ const handleDelete = () => {
+ const selection =
+ tab === 0 || tab === 1 || tab === 2 || tab === 3 ? selDups.map(item => item.photo) :
+ tab === 4 ? selUniq : [];
+ // Create urls for thumbnails
+ const photos: PhotoImageType[] = selection.map(photo => ({ ...photo, src: urls.thumb(photo) }));
+ // Show delete dialog
+ dialog.delete(collection, album, photos);
+ onClose();
+ };
+
+ const handleChangeTab = (_event: React.SyntheticEvent, newValue: number) => {
+ setTab(newValue);
+ // Selection is lost when swapping between tabs
+ setSelectingDups(false);
+ setSelectingUniq(false);
+ setSelDups([]);
+ setSelUniq([]);
+ };
+
+ return (
+
+ );
+}
+
+export default DuplicatesDialog;
diff --git a/src/dialogs/Move.tsx b/src/dialogs/Move.tsx
new file mode 100644
index 0000000..aaf581b
--- /dev/null
+++ b/src/dialogs/Move.tsx
@@ -0,0 +1,232 @@
+import { FC, useEffect, useState } from 'react';
+
+import Autocomplete from '@mui/material/Autocomplete';
+import Button from '@mui/material/Button';
+import CloseIcon from '@mui/icons-material/Close';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogContentText from '@mui/material/DialogContentText';
+import DialogTitle from '@mui/material/DialogTitle';
+import FormControl from '@mui/material/FormControl';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import FormHelperText from '@mui/material/FormHelperText';
+import FormLabel from '@mui/material/FormLabel';
+import IconButton from '@mui/material/IconButton';
+import InputLabel from '@mui/material/InputLabel';
+import MenuItem from '@mui/material/MenuItem';
+import Radio from '@mui/material/Radio';
+import RadioGroup from '@mui/material/RadioGroup';
+import Select, { SelectChangeEvent } from '@mui/material/Select';
+import TextField from '@mui/material/TextField';
+
+import PhotoAlbum from 'react-photo-album';
+
+import {
+ QueryAddAlbum,
+ QueryMovePhotos,
+ useAddAlbumMutation,
+ useGetAlbumsQuery,
+ useGetCollectionsQuery,
+ useMovePhotosMutation
+} from '../services/api';
+import useNotification from '../Notification';
+import { PhotoImageType, MoveConflictMode } from '../types';
+
+interface DialogProps {
+ open: boolean;
+ collection: string;
+ album: string;
+ photos: PhotoImageType[];
+ onClose: () => void;
+}
+
+const isValidAlbumName = (function () {
+ const rg1 = /^[^\\/:*?"<>|]+$/; // forbidden characters \ / : * ? " < > |
+ const rg2 = /^\./; // cannot start with dot (.)
+ const rg3 = /^(nul|prn|con|lpt[0-9]|com[0-9])(\.|$)/i; // forbidden file names
+ return (fname: string) => rg1.test(fname) && !rg2.test(fname) && !rg3.test(fname);
+})();
+
+function mostCommon(arr: PhotoImageType[]): string {
+ const dates = arr
+ .filter(v => !v?.date?.startsWith("0001-01-01"))
+ .map(v => v.date.slice(0, v.date.lastIndexOf('T')));
+
+ const dist: {[key: string]: number} = {};
+ let best = "";
+ dates.forEach((v) => {
+ dist[v] = (dist[v] || 0) + 1;
+ if(dist[v] > (dist[best] || 0))
+ best = v;
+ });
+ return best;
+}
+
+/* Dialog for move action */
+const MoveDialog: FC = ({collection, album, photos, open, onClose}) => {
+ const [movePhotos] = useMovePhotosMutation();
+ const [addAlbum] = useAddAlbumMutation();
+ const [targetCollection, setTargetCollection] = useState(collection);
+ const [targetAlbum, setTargetAlbum] = useState("");
+ const [mode, setMode] = useState(MoveConflictMode.Cancel);
+ const [isNewAlbum, setIsNewAlbum] = useState(false);
+ const [errorName, setErrorName] = useState(false);
+ const [processingAction, setProcessingAction] = useState(false);
+
+ const { successNotification, errorNotification } = useNotification();
+ const { data: collections = [], isFetching } = useGetCollectionsQuery();
+ const { data: tmpAlbums = [] } = useGetAlbumsQuery({ collection: targetCollection }, { skip: !open || isFetching || targetCollection === "" });
+ const albums = tmpAlbums.filter(v => !v.pseudo && v.name !== album);
+
+ // Set initial values
+ // collection
+ useEffect(() => {
+ if(!isFetching && targetCollection === "")
+ setTargetCollection(collection)
+ }, [open, collection, targetCollection, isFetching]);
+ // album
+ useEffect(() => setTargetAlbum(mostCommon(photos)), [open, album, photos]);
+
+ // Find out if it is a new album
+ useEffect(() => {
+ // Check if is not moving for the same album or it is a valid name
+ if((targetCollection === collection && targetAlbum === album) || !isValidAlbumName(targetAlbum)) {
+ setErrorName(true);
+ return;
+ }
+ // all good
+ setErrorName(false);
+ setIsNewAlbum(albums.find(a => a.name === targetAlbum) === undefined);
+ }, [collection, targetCollection, album, albums, targetAlbum]);
+
+ const changeCollection = (event: SelectChangeEvent) => {
+ setTargetCollection(event.target.value);
+ };
+
+ const changeAlbum = (_event: React.SyntheticEvent, value: string | null) => {
+ setTargetAlbum(value?.trim() || "");
+ };
+
+ const changeMode = (_event: React.ChangeEvent, value: string) => {
+ setMode(value as MoveConflictMode);
+ };
+
+ const handleClose = () => {
+ onClose();
+ }
+
+ const handleMove = async () => {
+ if(isNewAlbum) {
+ const addAlbumData: QueryAddAlbum = {
+ collection: targetCollection,
+ name: targetAlbum.trim(),
+ type: "regular"
+ }
+
+ // Form validation
+ if(!addAlbumData.name)
+ return;
+
+ setProcessingAction(true);
+ try {
+ await addAlbum(addAlbumData).unwrap();
+ successNotification(`Album created with name ${addAlbumData.name}`);
+ }
+ catch(error) {
+ setProcessingAction(false);
+ errorNotification(`Could not create album named ${addAlbumData.name}!`);
+ console.log(error);
+ return;
+ }
+ }
+
+ const query: QueryMovePhotos = {
+ collection,
+ album,
+ target: {
+ mode,
+ collection: targetCollection,
+ album: targetAlbum.trim(),
+ photos: photos.map(photo => photo.id),
+ }
+ };
+
+ setProcessingAction(true);
+ try {
+ const stats = await movePhotos(query).unwrap();
+ successNotification(`${stats.moved_photos} photos (${stats.moved_files} files)
+ were moved to ${album}, ${stats.skipped} skipped and ${stats.renamed} renamed.`);
+ onClose();
+ }
+ catch(error: any) {
+ errorNotification(`Error while moving photos: ${error.data.message}!`);
+ console.log(error);
+ }
+ setProcessingAction(false);
+ }
+
+ return (
+ );
+}
+
+export default MoveDialog;
diff --git a/src/dialogs/index.tsx b/src/dialogs/index.tsx
index 3296992..a17031a 100644
--- a/src/dialogs/index.tsx
+++ b/src/dialogs/index.tsx
@@ -1,6 +1,10 @@
-import React, { useState } from 'react'
+import React, { useState } from 'react';
+import DeleteAlbumDialog from './DeleteAlbum';
+import DeleteDialog from './Delete';
+import DuplicatesDialog from './Duplicates';
import Lightbox from './Lightbox';
+import MoveDialog from './Move';
import NewAlbumDialog from './NewAlbum';
import PhotoInfoDialog from './PhotoInfo';
@@ -10,18 +14,37 @@ interface DialogContext {
lightbox(photos: PhotoType[], selected: number): void;
newAlbum(): void;
info(photos: PhotoType[], selected: number): void;
+ move(collection: string, album: string, photos: PhotoImageType[]): void;
+ delete(collection: string, album: string, photos: PhotoImageType[]): void;
+ deleteAlbum(collection: string, album: string): void;
+ duplicates(collection: string, album: string): void;
}
const DialogContext = React.createContext({
lightbox: function (): void {},
newAlbum: function (): void {},
info: function (): void {},
+ move: function (): void {},
+ delete: function (): void {},
+ deleteAlbum: function (): void {},
+ duplicates: function (): void {},
});
interface DialogProviderProps {
children?: React.ReactNode;
}
+interface CollectionAlbum {
+ collection: string;
+ album: string;
+}
+
+interface CollectionAlbumPhotos {
+ collection: string;
+ album: string;
+ photos: PhotoImageType[];
+}
+
interface SelectedPhotos {
photos: PhotoImageType[];
selected: number;
@@ -31,6 +54,10 @@ const DialogProvider: React.FC = ({ children }) => {
const [lightbox, setLightbox] = useState(null);
const [newAlbum, setNewAlbum] = useState(false);
const [info, setInfo] = useState(null);
+ const [move, setMove] = useState(null);
+ const [del, setDelete] = useState(null);
+ const [deleteAlbum, setDeleteAlbum] = useState(null);
+ const [duplicates, setDuplicates] = useState(null);
// Lightbox
const openLightbox = (photos: PhotoImageType[], selected: number) => {
@@ -53,12 +80,44 @@ const DialogProvider: React.FC = ({ children }) => {
const closeInfo = () => {
setInfo(null);
}
+ // Move
+ const openMove = (collection: string, album: string, photos: PhotoImageType[]) => {
+ setMove({collection, album, photos});
+ }
+ const closeMove = () => {
+ setMove(null);
+ }
+ // Delete
+ const openDelete = (collection: string, album: string, photos: PhotoImageType[]) => {
+ setDelete({collection, album, photos});
+ }
+ const closeDelete = () => {
+ setDelete(null);
+ }
+ // Delete Album
+ const openDeleteAlbum = (collection: string, album: string) => {
+ setDeleteAlbum({collection, album});
+ }
+ const closeDeleteAlbum = () => {
+ setDeleteAlbum(null);
+ }
+ // Duplicates
+ const openDuplicates = (collection: string, album: string) => {
+ setDuplicates({collection, album});
+ }
+ const closeDuplicates = () => {
+ setDuplicates(null);
+ }
return (
{ children }
@@ -79,6 +138,32 @@ const DialogProvider: React.FC = ({ children }) => {
selected={info?.selected || 0}
onClose={closeInfo} />
+
+
+
+
+
+
+
+
);
}
diff --git a/src/favoriteHook.ts b/src/favoriteHook.ts
index 4de7134..2f1fecf 100644
--- a/src/favoriteHook.ts
+++ b/src/favoriteHook.ts
@@ -3,12 +3,14 @@ import { useParams } from "react-router-dom";
import { selectFavorite } from "./services/app";
import { QuerySaveFavorite, useSavePhotoToPseudoMutation } from './services/api';
+import { useSelection } from "./Selection";
import { PhotoImageType, PhotoType, PseudoAlbumType } from './types';
import useNotification from "./Notification";
const useFavorite = () => {
const favorite = useSelector(selectFavorite);
const { collection, album } = useParams();
+ const { indexes: getSelection } = useSelection();
const [saveFavorite] = useSavePhotoToPseudoMutation();
const { infoNotification, errorNotification } = useNotification();
@@ -32,7 +34,8 @@ const useFavorite = () => {
return;
}
- const indexes = [index];
+ const selection = getSelection();
+ const indexes = selection.includes(index) ? selection : [index];
const isFavorite = !(favoriteStatus(photos[index].favorite).isFavoriteThis);
const saveData = {
diff --git a/src/selection.scss b/src/selection.scss
new file mode 100644
index 0000000..31d8866
--- /dev/null
+++ b/src/selection.scss
@@ -0,0 +1,14 @@
+.selection_ {
+ &_not-draggable, &_not-draggable img {
+ -webkit-user-drag: none;
+ -khtml-user-drag: none;
+ -moz-user-drag: none;
+ -o-user-drag: none;
+ user-drag: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -o-user-select: none;
+ user-select: none;
+ }
+}
diff --git a/src/services/api.ts b/src/services/api.ts
index 182b34f..e9ba65a 100644
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -1,7 +1,11 @@
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
-import { CollectionType, PseudoAlbumType, AlbumType, PhotoType } from "../types";
+import { CollectionType, PseudoAlbumType, AlbumType, PhotoType, MoveConflictMode } from "../types";
import { changeFavorite } from "./app";
+export interface ResponseError {
+ message: string;
+}
+
export interface QueryAlbums {
collection?: string;
}
@@ -23,6 +27,32 @@ export interface QueryPhoto {
photo?: string;
}
+export interface QueryMovePhotos {
+ collection: CollectionType["name"];
+ album: AlbumType["name"];
+ target: {
+ mode: MoveConflictMode;
+ collection: CollectionType["name"];
+ album: AlbumType["name"];
+ photos: PhotoType["title"][];
+ }
+}
+
+export interface ResponseMovePhotos {
+ moved_photos: number;
+ moved_files: number;
+ skipped: number;
+ renamed: number;
+}
+
+export interface QueryDeletePhotos {
+ collection: CollectionType["name"];
+ album: AlbumType["name"];
+ target: {
+ photos: PhotoType["title"][];
+ }
+}
+
export interface QuerySaveFavorite {
collection: CollectionType["name"];
album: AlbumType["name"];
@@ -35,6 +65,37 @@ export interface QuerySaveFavorite {
}
}
+export interface Duplicate {
+ photo: PhotoType;
+ found: {
+ photo: PhotoType;
+ files: {
+ from: number;
+ to: number;
+ }[];
+ // Flags
+ equal: boolean; // All files were matched
+ partial: boolean; // Only some files were matched
+ incomplete: boolean; // Found photos with more files
+ conflict: boolean; // Combination of Partial and Incomplete
+ samealbum: boolean; // Photos are in the same album
+ }[];
+}
+export interface ResponseDuplicates {
+ albums: PseudoAlbumType[];
+ keep: Duplicate[];
+ delete: Duplicate[];
+ unique: PhotoType[];
+ conflict: Duplicate[];
+ samealbum: Duplicate[];
+ countKeep: number;
+ countDelete: number;
+ countUnique: number;
+ countConflict: number;
+ countSameAlbum: number;
+ total: number;
+}
+
const albumId = (arg: { collection: string, album: string }) => arg.collection + ":" + arg.album;
export const api = createApi({
@@ -67,6 +128,37 @@ export const api = createApi({
}),
invalidatesTags: [ 'Pseudo', 'Albums'],
}),
+ deleteAlbum: builder.mutation({
+ query: ({ collection, album }) => ({
+ url: `/collections/${collection}/albums/${album}`,
+ method: 'DELETE',
+ }),
+ invalidatesTags: (_result, _error, arg) => ['Pseudo', 'Albums', { type: 'Album', id: albumId(arg) }],
+ }),
+ duplicatedPhotos: builder.query({
+ query: ({ collection, album }) => `/collections/${collection}/albums/${album}/duplicates`
+ }),
+ movePhotos: builder.mutation({
+ query: ({ collection, album, target }) => ({
+ url: `/collections/${collection}/albums/${album}/photos/move`,
+ method: 'PUT',
+ body: target,
+ }),
+ invalidatesTags: (_result, _error, arg) => [
+ { type: 'Album', id: albumId(arg) },
+ { type: 'Album', id: albumId(arg.target) }
+ ],
+ }),
+ deletePhotos: builder.mutation({
+ query: ({ collection, album, target }) => ({
+ url: `/collections/${collection}/albums/${album}/photos`,
+ method: 'DELETE',
+ body: target,
+ }),
+ invalidatesTags: (_result, _error, arg) => [
+ { type: 'Album', id: albumId(arg) },
+ ],
+ }),
getPhotoInfo: builder.query({
query: ({collection, album, id }) => `/collections/${collection}/albums/${album}/photos/${id}/info`,
}),
@@ -109,6 +201,12 @@ export const {
useGetAlbumsQuery,
useGetAlbumQuery,
useAddAlbumMutation,
+ useDeleteAlbumMutation,
+ useDuplicatedPhotosQuery,
+ useMovePhotosMutation,
+ useDeletePhotosMutation,
useGetPhotoInfoQuery,
useSavePhotoToPseudoMutation,
} = api;
+
+export default api;
diff --git a/src/types.ts b/src/types.ts
index 500dabc..dca5fd8 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -57,6 +57,12 @@ export interface FileType {
export type PhotoImageType = PhotoType & Image;
+export enum MoveConflictMode {
+ Cancel = "cancel",
+ Skip = "skip",
+ Rename = "rename",
+}
+
export const urls = {
thumb: (photo: PhotoType) => `/api/collections/${photo.collection}/albums/${photo.album}/photos/${photo.id}/thumb`,
file: (photo: PhotoType, file: FileType) => `/api/collections/${photo.collection}/albums/${photo.album}/photos/${photo.id}/files/${file.id}`,