From 020d3c650ecbf1f7b81729516ebf665be593946b Mon Sep 17 00:00:00 2001 From: loloToster Date: Sat, 21 Dec 2024 17:09:53 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20added=20folder=20compressing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/package-lock.json | 137 +++++++++++++ client/package.json | 3 + client/src/App.tsx | 15 +- .../components/Compressing/Compressing.scss | 90 +++++++++ .../components/Compressing/Compressing.tsx | 30 +++ .../src/components/FolderItem/FolderItem.scss | 12 +- .../src/components/FolderItem/FolderItem.tsx | 11 ++ client/src/contexts/zipContext.tsx | 180 ++++++++++++++++++ 8 files changed, 471 insertions(+), 7 deletions(-) create mode 100644 client/src/components/Compressing/Compressing.scss create mode 100644 client/src/components/Compressing/Compressing.tsx create mode 100644 client/src/contexts/zipContext.tsx diff --git a/client/package-lock.json b/client/package-lock.json index 5e05f6d..cc197b6 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,12 +11,15 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^14.4.3", + "@types/file-saver": "^2.0.7", "@types/jest": "^27.5.2", "@types/node": "^16.11.56", "@types/react": "^18.0.18", "@types/react-dom": "^18.0.6", "@wavesurfer/react": "^1.0.7", + "file-saver": "^2.0.5", "highlight.js": "^11.10.0", + "jszip": "^3.10.1", "msw": "^0.47.3", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -3821,6 +3824,11 @@ "@types/range-parser": "*" } }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==" + }, "node_modules/@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -7975,6 +7983,11 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -9017,6 +9030,11 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/immer": { "version": "9.0.15", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.15.tgz", @@ -11792,6 +11810,39 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "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" + } + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -11849,6 +11900,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz", @@ -13091,6 +13150,11 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -15844,6 +15908,11 @@ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.5.1.tgz", "integrity": "sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ==" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -20619,6 +20688,11 @@ "@types/range-parser": "*" } }, + "@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==" + }, "@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -23684,6 +23758,11 @@ "schema-utils": "^3.0.0" } }, + "file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -24424,6 +24503,11 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==" }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "immer": { "version": "9.0.15", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.15.tgz", @@ -26418,6 +26502,41 @@ "object.assign": "^4.1.3" } }, + "jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "requires": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "requires": { + "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" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -26460,6 +26579,14 @@ "type-check": "~0.4.0" } }, + "lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "requires": { + "immediate": "~3.0.5" + } + }, "lilconfig": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz", @@ -27342,6 +27469,11 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -29154,6 +29286,11 @@ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.5.1.tgz", "integrity": "sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ==" }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/client/package.json b/client/package.json index c7229ce..2559bbe 100644 --- a/client/package.json +++ b/client/package.json @@ -6,12 +6,15 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.3.0", "@testing-library/user-event": "^14.4.3", + "@types/file-saver": "^2.0.7", "@types/jest": "^27.5.2", "@types/node": "^16.11.56", "@types/react": "^18.0.18", "@types/react-dom": "^18.0.6", "@wavesurfer/react": "^1.0.7", + "file-saver": "^2.0.5", "highlight.js": "^11.10.0", + "jszip": "^3.10.1", "msw": "^0.47.3", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 3ed8dde..d2138e1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,18 +1,25 @@ import { Outlet, ScrollRestoration } from "react-router-dom"; import { CacheContextProvider } from "./contexts/cacheContext"; +import { ZipContext, ZipContextProvider } from "./contexts/zipContext"; import { SearchContextProvider } from "./contexts/searchContext"; import Header from "./components/Header/Header"; +import Compressing from "./components/Compressing/Compressing"; function App() { return (
- -
- - + + +
+ + + + {({ zipsInProgress }) => Boolean(zipsInProgress) && } + +
diff --git a/client/src/components/Compressing/Compressing.scss b/client/src/components/Compressing/Compressing.scss new file mode 100644 index 0000000..33c6219 --- /dev/null +++ b/client/src/components/Compressing/Compressing.scss @@ -0,0 +1,90 @@ +@use "src/sass/abstract/colors" as c; + +.compressing { + position: fixed; + bottom: 32px; + right: 32px; + + @keyframes border-lighten { + 100% { + border-color: lighten(c.$main, 10%); + } + } + + @keyframes bg-lighten { + 100% { + background-color: lighten(c.$main, 10%); + } + } + + &__portrait { + display: none; + + position: relative; + padding: 18px; + background-color: c.$dark-grey; + border-radius: 100vh; + border: c.$main 2px solid; + animation: border-lighten 600ms infinite alternate; + + .material-symbols-rounded { + font-size: 38px; + } + + &__num { + position: absolute; + background-color: c.$main; + bottom: 0; + right: 0; + min-width: 1.3rem; + line-height: 1; + padding: 2px; + border-radius: 100vh; + text-align: center; + animation: bg-lighten 600ms infinite alternate; + } + } + + &__horizontal { + padding: 10px 16px; + background-color: c.$dark-grey; + border-radius: 100vh; + border: c.$main 2px solid; + animation: border-lighten 600ms infinite alternate; + + @keyframes jumping-dots { + 0% { + transform: translateY(0); + } + + 50% { + transform: translateY(-5%); + } + + 100% { + transform: translateY(0); + } + } + + &__dot { + display: inline-block; + animation: jumping-dots 900ms infinite; + + @for $i from 0 through 2 { + &:nth-child(#{$i + 1}) { + animation-delay: #{$i * 200ms}; + } + } + } + } + + @media (orientation: portrait) { + &__horizontal { + display: none; + } + + &__portrait { + display: block; + } + } +} diff --git a/client/src/components/Compressing/Compressing.tsx b/client/src/components/Compressing/Compressing.tsx new file mode 100644 index 0000000..6432ccf --- /dev/null +++ b/client/src/components/Compressing/Compressing.tsx @@ -0,0 +1,30 @@ +import { useZip } from "src/contexts/zipContext"; +import "./Compressing.scss"; + +function Compressing() { + const { zipsInProgress } = useZip(); + + let folderNaming = "folder"; + if (zipsInProgress > 1) folderNaming += "s"; + + return ( +
+
+ + Compressing {zipsInProgress} {folderNaming} + + + . + . + . + +
+
+ folder_zip +
{zipsInProgress}
+
+
+ ); +} + +export default Compressing; diff --git a/client/src/components/FolderItem/FolderItem.scss b/client/src/components/FolderItem/FolderItem.scss index f05b89d..3f1bc18 100644 --- a/client/src/components/FolderItem/FolderItem.scss +++ b/client/src/components/FolderItem/FolderItem.scss @@ -24,7 +24,7 @@ } &__actions { - display: none; + display: flex; position: absolute; top: 0; right: 0; @@ -40,7 +40,13 @@ } } - &:hover &__actions { - display: flex; + @media (hover: hover) and (pointer: fine) { + & &__actions { + display: none; + } + + &:hover &__actions { + display: flex; + } } } diff --git a/client/src/components/FolderItem/FolderItem.tsx b/client/src/components/FolderItem/FolderItem.tsx index cd90e40..6a00201 100644 --- a/client/src/components/FolderItem/FolderItem.tsx +++ b/client/src/components/FolderItem/FolderItem.tsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; import { ClientFolderJson } from "@backend-types/types"; import { ITEM_SELECT_CLASS } from "src/consts"; +import { useZip } from "src/contexts/zipContext"; import { useItemList } from "src/contexts/itemListContext"; import Item from "../Item/Item"; @@ -21,6 +22,8 @@ function FolderItem(props: { item: ClientFolderJson }) { handleRestore, } = useItemList(); + const { downloadFolder } = useZip(); + const [moreOpen, setMoreOpen] = useState(false); const elIdentifier = "item-" + item.id; @@ -64,6 +67,14 @@ function FolderItem(props: { item: ClientFolderJson }) { open={moreOpen} onClose={() => setMoreOpen(false)} > + {Boolean(item.trashed) && ( )} diff --git a/client/src/contexts/zipContext.tsx b/client/src/contexts/zipContext.tsx new file mode 100644 index 0000000..ed874cd --- /dev/null +++ b/client/src/contexts/zipContext.tsx @@ -0,0 +1,180 @@ +import { createContext, useContext, useState } from "react"; +import { saveAs } from "file-saver"; +import JSZip from "jszip"; + +import { FolderJson, Item } from "@backend-types/types"; + +enum FolderZipElementType { + FOLDER, + FILE, +} + +type FolderZipElement = + | { + type: FolderZipElementType.FILE; + name: string; + blob: Blob; + } + | { + type: FolderZipElementType.FOLDER; + name: string; + items: FolderZipElement[]; + }; + +async function fetchFile(url: string): Promise { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch file from ${url}`); + } + + return await response.blob(); +} + +async function fetchFolder(folder: FolderJson): Promise { + const res = await fetch("/api/folder/" + folder.id); + const folderItems: Item[] = await res.json(); + + const subitems: FolderZipElement[] = []; + + // handle duplicate names + const names: Record = {}; + const addName = (name: string) => { + const nameUsed = names[name]; + names[name] = nameUsed ? nameUsed + 1 : 1; + }; + + for (const item of folderItems) { + let el: FolderZipElement; + + switch (item.type) { + case "img": + case "file": { + const name = item.title; + + el = { + type: FolderZipElementType.FILE, + name, + blob: await fetchFile("/cdn/" + item.id), + }; + + addName(name); + + break; + } + + case "text": { + const name = item.title + ".txt"; + + el = { + type: FolderZipElementType.FILE, + name, + blob: new Blob([item.text], { + type: "text/plain", + }), + }; + + addName(name); + + break; + } + + case "folder": { + el = await fetchFolder(item); + addName(el.name); + break; + } + } + + subitems.push(el); + } + + // handle duplicate names + for (const name in names) { + const nameUsed = names[name]; + if (!nameUsed || nameUsed <= 1) continue; + + let renames = 1; + + for (const subitem of subitems) { + if (subitem.name !== name) continue; + subitem.name = `${renames}_${subitem.name}`; + renames++; + } + } + + return { + type: FolderZipElementType.FOLDER, + name: folder.title, + items: subitems, + }; +} + +async function addToZip(zip: JSZip, el: FolderZipElement): Promise { + if (el.type === FolderZipElementType.FILE) { + zip.file(el.name, el.blob); + } else if (el.type === FolderZipElementType.FOLDER) { + const folder = zip.folder(el.name); + + if (folder) { + for (const item of el.items) { + await addToZip(folder, item); + } + } + } +} + +export async function createZipFromFolderZipElement( + rootElement: FolderZipElement +): Promise { + const zip = new JSZip(); + + if (rootElement.type === FolderZipElementType.FOLDER) { + for (const item of rootElement.items) { + await addToZip(zip, item); + } + } else { + throw new Error("Root element must be a folder"); + } + + return zip.generateAsync({ type: "blob" }); +} + +export interface ZipContextI { + downloadFolder: (folder: FolderJson) => void; + zipsInProgress: number; +} + +export const ZipContext = createContext({ + downloadFolder: () => null, + zipsInProgress: 0, +}); + +export const ZipContextProvider = (props: { children: React.ReactNode }) => { + const [zipsInProgress, setZipsInProgress] = useState(0); + + const downloadFolder = async (folder: FolderJson) => { + try { + setZipsInProgress((p) => p + 1); + + const fetchedFolder = await fetchFolder(folder); + const zip = await createZipFromFolderZipElement(fetchedFolder); + + saveAs(zip, folder.title + ".zip"); + } catch (err) { + console.error(err); + } finally { + setZipsInProgress((p) => p - 1); + } + }; + + return ( + + {props.children} + + ); +}; + +export const useZip = () => { + return useContext(ZipContext); +};