From 1d9f3b575d3479b2ab9aa354c6ff87fb91bad4dc Mon Sep 17 00:00:00 2001 From: Celina Ryholt Date: Wed, 23 Mar 2022 10:21:45 +0100 Subject: [PATCH] Add photo album download --- app/actions/ActionTypes.js | 1 + app/actions/GalleryPictureActions.js | 10 +++ app/reducers/galleryPictures.js | 18 +++++ app/routes/photos/GalleryDetailRoute.js | 8 ++- app/routes/photos/components/GalleryDetail.js | 71 ++++++++++++++++++- package.json | 2 + yarn.lock | 70 ++++++++++++++++-- 7 files changed, 173 insertions(+), 7 deletions(-) diff --git a/app/actions/ActionTypes.js b/app/actions/ActionTypes.js index 3b52d1ace4..3115c10e1f 100644 --- a/app/actions/ActionTypes.js +++ b/app/actions/ActionTypes.js @@ -97,6 +97,7 @@ export const GalleryPicture = { EDIT: (generateStatuses('GalleryPicture.EDIT'): AAT), DELETE: (generateStatuses('GalleryPicture.DELETE'): AAT), UPLOAD: (generateStatuses('GalleryPicture.UPLOAD'): AAT), + CLEAR: 'GalleryPicture.CLEAR', }; /** diff --git a/app/actions/GalleryPictureActions.js b/app/actions/GalleryPictureActions.js index ce108144b9..df8306780d 100644 --- a/app/actions/GalleryPictureActions.js +++ b/app/actions/GalleryPictureActions.js @@ -172,3 +172,13 @@ export function uploadAndCreateGalleryPicture( return uploadGalleryPicturesInTurn(files, galleryId, dispatch); }; } + +export function clear(galleryId: number): Thunk { + return (dispatch) => + dispatch({ + type: GalleryPicture.CLEAR, + meta: { + id: galleryId, + }, + }); +} diff --git a/app/reducers/galleryPictures.js b/app/reducers/galleryPictures.js index dd8e0bb242..6e85b6a39e 100644 --- a/app/reducers/galleryPictures.js +++ b/app/reducers/galleryPictures.js @@ -91,6 +91,24 @@ function mutateGalleryPicture(state: any, action: any) { }, }; } + case GalleryPicture.CLEAR: { + const newById = Object.fromEntries( + // Not using Object.entries() since flow will complain... + Object.keys(state.byId) + .map((key) => [key, state.byId[key]]) + .filter(([_, v: GalleryPictureEntity]) => { + return v.gallery !== action.meta.id; + }) + ); + const newItems = Object.keys(newById).map((id) => parseInt(id)); + return { + ...state, + byId: newById, + items: newItems, + pagination: {}, + paginationNext: {}, + }; + } default: return state; } diff --git a/app/routes/photos/GalleryDetailRoute.js b/app/routes/photos/GalleryDetailRoute.js index f581c6de73..b6725ce55d 100644 --- a/app/routes/photos/GalleryDetailRoute.js +++ b/app/routes/photos/GalleryDetailRoute.js @@ -10,6 +10,7 @@ import loadingIndicator from 'app/utils/loadingIndicator'; import prepare from 'app/utils/prepare'; import { fetch, + clear, uploadAndCreateGalleryPicture, } from 'app/actions/GalleryPictureActions'; import { push } from 'connected-react-router'; @@ -69,7 +70,12 @@ const propertyGenerator = (props, config) => { ]; }; -const mapDispatchToProps = { push, fetch, uploadAndCreateGalleryPicture }; +const mapDispatchToProps = { + push, + fetch, + clear, + uploadAndCreateGalleryPicture, +}; function metadataHelper() { return (ActualComponent) => { diff --git a/app/routes/photos/components/GalleryDetail.js b/app/routes/photos/components/GalleryDetail.js index b76ca02d22..2612b43f20 100644 --- a/app/routes/photos/components/GalleryDetail.js +++ b/app/routes/photos/components/GalleryDetail.js @@ -13,7 +13,9 @@ import type { DropFile } from 'app/components/Upload/ImageUpload'; import type { ID, ActionGrant } from 'app/models'; import type { GalleryPictureEntity } from 'app/reducers/galleryPictures'; import Button from 'app/components/Button'; - +import JsZip from 'jszip'; +import FileSaver from 'file-saver'; +import LoadingIndicator from 'app/components/LoadingIndicator'; type Props = { gallery: Object, loggedIn: boolean, @@ -23,6 +25,7 @@ type Props = { fetching: boolean, children: Element<*>, fetch: (galleryId: Number, args: { next: boolean }) => Promise<*>, + clear: (galleryId: Number) => Promise<*>, push: (string) => Promise<*>, uploadAndCreateGalleryPicture: (ID, File | Array) => Promise<*>, actionGrant: ActionGrant, @@ -30,11 +33,13 @@ type Props = { type State = { upload: boolean, + downloading: boolean, }; export default class GalleryDetail extends Component { state = { upload: false, + downloading: false, }; toggleUpload = (response?: File | Array) => { @@ -49,6 +54,55 @@ export default class GalleryDetail extends Component { this.props.push(`/photos/${this.props.gallery.id}/picture/${picture.id}`); }; + downloadGallery = () => { + this.setState({ downloading: true }); + // Force re-fetch to avoid expired image urls + this.props.clear(this.props.gallery.id); + const finishDownload = () => this.setState({ downloading: false }); + this.downloadNext(0, []) + .then((blobs) => { + const names = this.props.pictures.map((picture) => + picture.file.split('/').pop() + ); + this.zipFiles(this.props.gallery.title, names, blobs).finally( + finishDownload + ); + }) + .catch(finishDownload); + }; + + downloadNext = (index: number, blobsAccum: Blob[]) => { + return this.props + .fetch(this.props.gallery.id, { next: true, filters: {} }) + .then(() => { + const urls = this.props.pictures + .slice(index) + .map((picture) => picture.rawFile); + return this.downloadFiles(urls).then((blobs) => { + blobsAccum.push(...blobs); + if (this.props.hasMore) { + return this.downloadNext(this.props.pictures.length, blobsAccum); + } + return blobsAccum; + }); + }); + }; + + downloadFiles = (urls: string[]) => + Promise.all( + urls.map(async (url) => await fetch(url).then((res) => res.blob())) + ); + + zipFiles = (zipTitle: string, fileNames: string[], blobs: Blob[]) => { + const zip = JsZip(); + blobs.forEach((blob, i) => { + zip.file(fileNames[i], blob); + }); + return zip + .generateAsync({ type: 'blob' }) + .then((zipFile) => FileSaver.saveAs(zipFile, `${zipTitle}.zip`)); + }; + render() { const { gallery, @@ -68,7 +122,20 @@ export default class GalleryDetail extends Component { } + details={ + <> + +
+ {this.state.downloading ? ( + + ) : ( + + )} +
+ + } > { diff --git a/package.json b/package.json index 7baf2862e6..513f1015ce 100644 --- a/package.json +++ b/package.json @@ -52,11 +52,13 @@ "core-js": "^3.21.1", "crypto-browserify": "^3.12.0", "es6-promise-pool": "^2.5.0", + "file-saver": "^2.0.5", "fuzzy": "^0.1.3", "immer": "^9.0.12", "isomorphic-fetch": "3.0.0", "js-cookie": "^2.1.4", "jsdom": "^19.0.0", + "jszip": "^3.7.1", "jwt-decode": "3.1.2", "lodash-es": "^4.17.21", "mazemap": "file:mazemap", diff --git a/yarn.lock b/yarn.lock index aa0659a49e..65547b6db5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3229,6 +3229,11 @@ core-util-is@1.0.2: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.1.tgz#714d756522cace867867ccb4474c5d01bbae5d6d" @@ -4808,6 +4813,11 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-saver@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38" + integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA== + file-selector@^0.2.2: version "0.2.4" resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.2.4.tgz#7b98286f9dbb9925f420130ea5ed0a69238d4d80" @@ -5504,7 +5514,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -5817,7 +5827,7 @@ isarray@0.0.1: resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= -isarray@^1.0.0: +isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= @@ -6481,6 +6491,16 @@ jsprim@^2.0.2: array-includes "^3.1.3" object.assign "^4.1.2" +jszip@^3.7.1: + version "3.7.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.7.1.tgz#bd63401221c15625a1228c556ca8a68da6fda3d9" + integrity sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + set-immediate-shim "~1.0.1" + jwt-decode@3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" @@ -6556,6 +6576,13 @@ lie@3.1.1: dependencies: immediate "~3.0.5" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lilconfig@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.4.tgz#f4507d043d7058b380b6a8f5cb7bcd4b34cee082" @@ -7340,6 +7367,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -8069,6 +8101,11 @@ pretty-format@^27.5.1: ansi-styles "^5.0.0" react-is "^17.0.1" +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -8652,6 +8689,19 @@ readable-stream@^3.1.1, readable-stream@^3.5.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@~2.3.6: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + recharts-scale@^0.4.4: version "0.4.5" resolved "https://registry.yarnpkg.com/recharts-scale/-/recharts-scale-0.4.5.tgz#0969271f14e732e642fcc5bd4ab270d6e87dd1d9" @@ -8960,7 +9010,7 @@ rxjs@^7.5.1: dependencies: tslib "^2.1.0" -safe-buffer@5.1.2, safe-buffer@~5.1.1: +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== @@ -9098,6 +9148,11 @@ serve-static@1.14.2, serve-static@^1.14.1: parseurl "~1.3.3" send "0.17.2" +set-immediate-shim@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" + integrity sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E= + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" @@ -9451,6 +9506,13 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -10000,7 +10062,7 @@ use-latest@^1.0.0: dependencies: use-isomorphic-layout-effect "^1.0.0" -util-deprecate@^1.0.1, util-deprecate@^1.0.2: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=