From e4be2cbba36045887d811f7d4fded2d811f2c3e6 Mon Sep 17 00:00:00 2001 From: Pogodaanton <8974778+Pogodaanton@users.noreply.github.com> Date: Sat, 9 May 2020 21:56:02 +0200 Subject: [PATCH] + CRA rewired for PHP support --- .DS_Store | Bin 6148 -> 8196 bytes .eslintrc | 19 + .gitignore | 32 + .gitmodules | 3 + .prettierrc | 11 + README.md | 34 +- package.json | 72 + public/.htaccess | 33 + public/api/edit.php | 117 + public/api/get.php | 21 + public/api/getAll.php | 26 + public/api/login.php | 28 + public/api/logout.php | 6 + public/api/upload.php | 40 + public/index.php | 99 + public/manifest.json | 25 + public/protected/config.sample.php | 57 + public/protected/db.inc.php | 101 + public/protected/output.inc.php | 43 + public/protected/uploaders/image.inc.php | 216 + public/robots.txt | 3 + public/static/locales/de/common.json | 27 + public/static/locales/de/dashboard.json | 29 + public/static/locales/de/fileview.json | 12 + public/static/locales/en/common.json | 27 + public/static/locales/en/dashboard.json | 29 + public/static/locales/en/fileview.json | 12 + public/static/media/favicon.ico | Bin 0 -> 3870 bytes public/static/media/logo192.png | Bin 0 -> 5347 bytes public/static/media/logo512.png | Bin 0 -> 9664 bytes scripts/config-overrides.js | 117 + scripts/watch.js | 166 + src/App/AnimatedRoutes.tsx | 127 + src/App/App.props.ts | 17 + src/App/App.tsx | 31 + src/Dashboard/Dashboard.props.ts | 15 + src/Dashboard/Dashboard.tsx | 95 + src/Dashboard/views/DashboardEmpty.props.ts | 15 + src/Dashboard/views/DashboardEmpty.tsx | 45 + src/DashboardList/DashboardList.props.ts | 28 + src/DashboardList/DashboardList.tsx | 183 + .../views/DashboardListCell.props.tsx | 28 + src/DashboardList/views/DashboardListCell.tsx | 232 + .../views/DashboardListToolbar.props.ts | 19 + .../views/DashboardListToolbar.tsx | 49 + src/FileView/FileView.props.ts | 17 + src/FileView/FileView.tsx | 149 + .../views/FVSidebar/FVSidebar.props.ts | 19 + src/FileView/views/FVSidebar/FVSidebar.tsx | 151 + .../views/FVSidebar/FVSidebarContent.props.ts | 17 + .../views/FVSidebar/FVSidebarContent.tsx | 67 + .../views/FVSidebar/FVSidebarContext.props.ts | 31 + .../views/FVSidebar/FVSidebarContext.tsx | 43 + .../views/FVSidebar/FVSidebarDeleteButton.tsx | 48 + .../FVSidebar/FVSidebarDescEditor.props.ts | 17 + .../views/FVSidebar/FVSidebarDescEditor.tsx | 121 + .../views/FVSidebar/FVSidebarFooter.props.ts | 18 + .../views/FVSidebar/FVSidebarFooter.tsx | 127 + .../FVSidebar/FVSidebarToggleButton.props.ts | 14 + .../views/FVSidebar/FVSidebarToggleButton.tsx | 132 + .../HeaderContent/HeaderCenterContent.tsx | 23 + .../HeaderContent/HeaderRightContent.props.ts | 15 + .../HeaderContent/HeaderRightContent.tsx | 75 + .../views/ImageViewer/ImageViewer.props.ts | 20 + .../views/ImageViewer/ImageViewer.tsx | 432 + .../ImageViewer/ImageViewerSlider.props.ts | 21 + .../views/ImageViewer/ImageViewerSlider.tsx | 171 + .../ImageViewer/useViewportDimensions.ts | 27 + .../FullscreenDropzone.props.ts | 22 + src/FullscreenDropzone/FullscreenDropzone.tsx | 225 + .../views/DropzoneDrag.props.ts | 15 + src/FullscreenDropzone/views/DropzoneDrag.tsx | 50 + .../views/DropzoneError.props.ts | 17 + .../views/DropzoneError.tsx | 37 + .../views/DropzoneUpload.props.ts | 25 + .../views/DropzoneUpload.tsx | 183 + .../views/DropzoneUploadManager.props.ts | 20 + .../views/DropzoneUploadManager.tsx | 121 + src/Loader/FullscreenLoader.props.ts | 17 + src/Loader/FullscreenLoader.tsx | 49 + src/Loader/index.ts | 1 + src/Login/Login.props.ts | 25 + src/Login/Login.tsx | 104 + src/NotFound/NotFound.props.ts | 15 + src/NotFound/NotFound.tsx | 60 + src/_DesignSystem/Button/Button.props.ts | 54 + src/_DesignSystem/Button/Button.tsx | 111 + src/_DesignSystem/Button/ButtonStyle.ts | 189 + src/_DesignSystem/Header/Header.props.ts | 64 + src/_DesignSystem/Header/Header.tsx | 121 + .../Hooks/useMotionValueFactory.tsx | 29 + .../Hooks/useWindowBreakpoint.tsx | 34 + src/_DesignSystem/Logo/Logo.tsx | 40 + .../ProgressIcon/ProgressIcon.props.ts | 19 + .../ProgressIcon/ProgressIcon.tsx | 36 + src/_DesignSystem/Toast/Toast.props.ts | 20 + src/_DesignSystem/Toast/Toast.tsx | 80 + .../Toast/ToastContainer.props.ts | 16 + src/_DesignSystem/Toast/ToastContainer.tsx | 35 + src/_DesignSystem/Toast/useToasts.ts | 41 + .../Toolkit/DesignSystem.props.ts | 34 + src/_DesignSystem/Toolkit/DesignSystem.tsx | 132 + src/_DesignSystem/Toolkit/index.ts | 1 + src/_DesignSystem/Utils/iconToGlyph.tsx | 13 + .../Utils/stylesheetModifiers.ts | 45 + .../Utils/stylesheetModifiers.types.ts | 19 + src/_DesignSystem/index.ts | 30 + src/_interceptedAxios/index.ts | 25 + src/global.d.ts | 36 + src/i18n.ts | 36 + src/index.scss | 19 + src/index.tsx | 34 + src/react-app-env.d.ts | 4 + src/serviceWorker.ts | 149 + src/setupTests.ts | 20 + submodules/getID3 | 1 + tsconfig.json | 20 + yarn.lock | 12174 ++++++++++++++++ 118 files changed, 18709 insertions(+), 2 deletions(-) create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .prettierrc create mode 100644 package.json create mode 100755 public/.htaccess create mode 100755 public/api/edit.php create mode 100755 public/api/get.php create mode 100755 public/api/getAll.php create mode 100755 public/api/login.php create mode 100755 public/api/logout.php create mode 100755 public/api/upload.php create mode 100755 public/index.php create mode 100755 public/manifest.json create mode 100755 public/protected/config.sample.php create mode 100755 public/protected/db.inc.php create mode 100755 public/protected/output.inc.php create mode 100755 public/protected/uploaders/image.inc.php create mode 100755 public/robots.txt create mode 100755 public/static/locales/de/common.json create mode 100755 public/static/locales/de/dashboard.json create mode 100755 public/static/locales/de/fileview.json create mode 100755 public/static/locales/en/common.json create mode 100755 public/static/locales/en/dashboard.json create mode 100755 public/static/locales/en/fileview.json create mode 100755 public/static/media/favicon.ico create mode 100755 public/static/media/logo192.png create mode 100755 public/static/media/logo512.png create mode 100644 scripts/config-overrides.js create mode 100644 scripts/watch.js create mode 100755 src/App/AnimatedRoutes.tsx create mode 100755 src/App/App.props.ts create mode 100755 src/App/App.tsx create mode 100755 src/Dashboard/Dashboard.props.ts create mode 100755 src/Dashboard/Dashboard.tsx create mode 100755 src/Dashboard/views/DashboardEmpty.props.ts create mode 100755 src/Dashboard/views/DashboardEmpty.tsx create mode 100755 src/DashboardList/DashboardList.props.ts create mode 100755 src/DashboardList/DashboardList.tsx create mode 100755 src/DashboardList/views/DashboardListCell.props.tsx create mode 100755 src/DashboardList/views/DashboardListCell.tsx create mode 100755 src/DashboardList/views/DashboardListToolbar.props.ts create mode 100755 src/DashboardList/views/DashboardListToolbar.tsx create mode 100755 src/FileView/FileView.props.ts create mode 100755 src/FileView/FileView.tsx create mode 100755 src/FileView/views/FVSidebar/FVSidebar.props.ts create mode 100755 src/FileView/views/FVSidebar/FVSidebar.tsx create mode 100755 src/FileView/views/FVSidebar/FVSidebarContent.props.ts create mode 100755 src/FileView/views/FVSidebar/FVSidebarContent.tsx create mode 100755 src/FileView/views/FVSidebar/FVSidebarContext.props.ts create mode 100755 src/FileView/views/FVSidebar/FVSidebarContext.tsx create mode 100755 src/FileView/views/FVSidebar/FVSidebarDeleteButton.tsx create mode 100755 src/FileView/views/FVSidebar/FVSidebarDescEditor.props.ts create mode 100755 src/FileView/views/FVSidebar/FVSidebarDescEditor.tsx create mode 100755 src/FileView/views/FVSidebar/FVSidebarFooter.props.ts create mode 100755 src/FileView/views/FVSidebar/FVSidebarFooter.tsx create mode 100755 src/FileView/views/FVSidebar/FVSidebarToggleButton.props.ts create mode 100755 src/FileView/views/FVSidebar/FVSidebarToggleButton.tsx create mode 100755 src/FileView/views/HeaderContent/HeaderCenterContent.tsx create mode 100755 src/FileView/views/HeaderContent/HeaderRightContent.props.ts create mode 100755 src/FileView/views/HeaderContent/HeaderRightContent.tsx create mode 100755 src/FileView/views/ImageViewer/ImageViewer.props.ts create mode 100755 src/FileView/views/ImageViewer/ImageViewer.tsx create mode 100755 src/FileView/views/ImageViewer/ImageViewerSlider.props.ts create mode 100755 src/FileView/views/ImageViewer/ImageViewerSlider.tsx create mode 100755 src/FileView/views/ImageViewer/useViewportDimensions.ts create mode 100755 src/FullscreenDropzone/FullscreenDropzone.props.ts create mode 100755 src/FullscreenDropzone/FullscreenDropzone.tsx create mode 100755 src/FullscreenDropzone/views/DropzoneDrag.props.ts create mode 100755 src/FullscreenDropzone/views/DropzoneDrag.tsx create mode 100755 src/FullscreenDropzone/views/DropzoneError.props.ts create mode 100755 src/FullscreenDropzone/views/DropzoneError.tsx create mode 100755 src/FullscreenDropzone/views/DropzoneUpload.props.ts create mode 100755 src/FullscreenDropzone/views/DropzoneUpload.tsx create mode 100755 src/FullscreenDropzone/views/DropzoneUploadManager.props.ts create mode 100755 src/FullscreenDropzone/views/DropzoneUploadManager.tsx create mode 100755 src/Loader/FullscreenLoader.props.ts create mode 100755 src/Loader/FullscreenLoader.tsx create mode 100755 src/Loader/index.ts create mode 100755 src/Login/Login.props.ts create mode 100755 src/Login/Login.tsx create mode 100755 src/NotFound/NotFound.props.ts create mode 100755 src/NotFound/NotFound.tsx create mode 100755 src/_DesignSystem/Button/Button.props.ts create mode 100755 src/_DesignSystem/Button/Button.tsx create mode 100755 src/_DesignSystem/Button/ButtonStyle.ts create mode 100755 src/_DesignSystem/Header/Header.props.ts create mode 100755 src/_DesignSystem/Header/Header.tsx create mode 100755 src/_DesignSystem/Hooks/useMotionValueFactory.tsx create mode 100755 src/_DesignSystem/Hooks/useWindowBreakpoint.tsx create mode 100755 src/_DesignSystem/Logo/Logo.tsx create mode 100755 src/_DesignSystem/ProgressIcon/ProgressIcon.props.ts create mode 100755 src/_DesignSystem/ProgressIcon/ProgressIcon.tsx create mode 100755 src/_DesignSystem/Toast/Toast.props.ts create mode 100755 src/_DesignSystem/Toast/Toast.tsx create mode 100755 src/_DesignSystem/Toast/ToastContainer.props.ts create mode 100755 src/_DesignSystem/Toast/ToastContainer.tsx create mode 100755 src/_DesignSystem/Toast/useToasts.ts create mode 100755 src/_DesignSystem/Toolkit/DesignSystem.props.ts create mode 100755 src/_DesignSystem/Toolkit/DesignSystem.tsx create mode 100755 src/_DesignSystem/Toolkit/index.ts create mode 100755 src/_DesignSystem/Utils/iconToGlyph.tsx create mode 100755 src/_DesignSystem/Utils/stylesheetModifiers.ts create mode 100644 src/_DesignSystem/Utils/stylesheetModifiers.types.ts create mode 100755 src/_DesignSystem/index.ts create mode 100755 src/_interceptedAxios/index.ts create mode 100755 src/global.d.ts create mode 100755 src/i18n.ts create mode 100755 src/index.scss create mode 100755 src/index.tsx create mode 100644 src/react-app-env.d.ts create mode 100644 src/serviceWorker.ts create mode 100755 src/setupTests.ts create mode 160000 submodules/getID3 create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.DS_Store b/.DS_Store index 3e391de0f729d619dabea1aac629bcd83153700f..b1bf7e594ce138ae8cb4d9629bf62933dc50f467 100644 GIT binary patch literal 8196 zcmeHMO-~y~7=8z6uoDPNfI`w%vT8+&6o{~rAVn35V?eFd4=6?iN)l(ka5k)Wtlc#f zD3ve0R_d*X<_GlDA5f{M9(t(MV^39oLL>FmQ~S)!;@IoD;#9TDj5PDUJM%pA@vP=u zUPDBz)N~3&Gend@71p?b=5LA^7qzDhBt7G>416MwYBWcj_Q~x*>kh4eRzNGD70?Q3 z1^x>P;F~QB`dQeLt%1t@NyDPtf;JRwE|j!UIj$% zK0!8e_JDS~^7oKJJUhtbBf3FPDJuKiwtQ}ZAuiclsMr3K1)c~24)77hDcsj7+%UaI zWm=*Y`hY65(v4=*Df9^T#pN!lRF__pe6gn#@3XV=tb?eLoZ?5T;d@a$KmRiu7#w=# z+;Ap~GMar@dBP7Wu^Bg`k{RxZr!_NvVkQ0t=YHEatxeB8s2YZCdMbn zr(T_!o|(FQ<;v?-W5P1+y;>;tzr=kWmi(r>Qwu$((lBi=sBOjW$8p6wa;wIr6m;En z9=+(=IBWnj`JzKk_jc&Ift+u3cQ*ZjV zW5s;2hHffKJ=nHyA-_W=8 z1N}n3(I0G>U1o2x>+BX=WOvy+>_fKBHrO`%i0vr;!0B6lBbhHT%z@MQE$>&tz~?~? z7{MQ*2AR0oduVI8G4c+Jd!D*EzXRKOkI#YEBn!99BfA?rV5YEHTE{)UN6R?t3Rb9- zlVbdoVhkff*fO#-C{Ewvo|rNSbeP@+OW$=dCs0_QW7nx23@)a2a2{xJzJG`&J)BXo z*RCkpM@@NxFFT^*MLLB3A)?qE-hqgz9Q6kF$*I<@m5hCz+-#ex@OYQq3=*-mlYvKF zv;t>TU{K*^ME<|h|M&kh&Zc2n0j +Because webpack-dev-server cannot interpret PHP code, it is required to route a **PHP-(MySQL/MariaDB)-server** to the automatically generated `dist` directory. + +The page will reload if you make edits.
+You will also see any lint errors in the console. + +### `yarn build` + +Builds the app for production to the `build` folder.
+It correctly bundles React in production mode and optimizes the build for the best performance. + +This build is minified and the filenames include hashes for cache prevention.
+See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +## Learn More + +You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). + +To learn React, check out the [React documentation](https://reactjs.org/). diff --git a/package.json b/package.json new file mode 100644 index 0000000..b093ec8 --- /dev/null +++ b/package.json @@ -0,0 +1,72 @@ +{ + "name": "shadis", + "version": "0.1.0", + "private": true, + "dependencies": { + "@loadable/component": "^5.12.0", + "@microsoft/fast-colors": "^5.0.8", + "@microsoft/fast-components-react-base": "^4.25.10", + "@microsoft/fast-components-react-msft": "^4.30.10", + "@microsoft/fast-components-styles-msft": "^4.28.9", + "@microsoft/fast-jss-manager-react": "^4.7.2", + "@microsoft/fast-jss-utilities": "^4.7.12", + "@microsoft/fast-web-utilities": "^4.4.5", + "@popmotion/easing": "^1.0.2", + "@testing-library/jest-dom": "^4.2.4", + "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^7.1.2", + "@types/jest": "^24.0.0", + "@types/loadable__component": "^5.10.0", + "@types/lodash-es": "^4.17.3", + "@types/node": "^12.0.0", + "@types/react": "^16.9.0", + "@types/react-dom": "^16.9.0", + "@types/react-router-dom": "^5.1.5", + "@types/react-toast-notifications": "^2.4.0", + "@types/react-virtualized": "^9.21.9", + "axios": "^0.19.2", + "copy-webpack-plugin": "^5.1.1", + "focus-visible": "^4.1.5", + "framer-motion": "^2.0.0-beta.66", + "i18next": "^19.4.4", + "i18next-browser-languagedetector": "^4.1.1", + "i18next-xhr-backend": "^3.2.2", + "lodash-es": "^4.17.15", + "node-sass": "^4.14.1", + "popmotion": "^8.7.3", + "prettier": "^2.0.5", + "react": "^16.13.1", + "react-app-rewired": "^2.1.6", + "react-dom": "^16.13.1", + "react-dropzone": "^11.0.1", + "react-i18next": "^11.4.0", + "react-icons": "^3.10.0", + "react-resize-aware": "^3.0.0", + "react-router-dom": "^5.1.2", + "react-scripts": "3.4.1", + "react-toast-notifications": "^2.4.0", + "react-virtualized": "^9.21.2", + "rewire": "^5.0.0", + "typescript": "~3.8.3", + "webpack-livereload-plugin": "^2.3.0" + }, + "scripts": { + "start": "node scripts/watch.js", + "serve": "node scripts/watch.js", + "build": "react-app-rewired build", + "test": "react-app-rewired test" + }, + "config-overrides-path": "scripts/config-overrides", + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/public/.htaccess b/public/.htaccess new file mode 100755 index 0000000..4d2598a --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,33 @@ +ErrorDocument 404 / +ErrorDocument 403 / +IndexIgnore * + + + RewriteEngine On + RewriteBase / + + # Force trailing slash + RewriteCond %{REQUEST_URI} /+[^\.]+$ + RewriteRule ^(.+[^/])$ %{REQUEST_URI}/ [R=301,L] + + # Rewrite jpg/png/gif/webp file access to uploads folder + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{DOCUMENT_ROOT}/uploads/$1.$2 -f + RewriteRule ^(.+)\.(jpg|png|gif|webp)$ uploads/$1.$2 [L] + + # Don't rewrite files or directories + RewriteRule ^index.php$ - [L] + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + + # Rewrite everything else to index.php to allow html5 state links + RewriteRule . /index.php [L] + + + + + + SetOutputFilter DEFLATE + + \ No newline at end of file diff --git a/public/api/edit.php b/public/api/edit.php new file mode 100755 index 0000000..230c91e --- /dev/null +++ b/public/api/edit.php @@ -0,0 +1,117 @@ +token; +$selection = $_POST->selection; +$action = $_POST->action; + +/** + * Login check + * If an edit token was given, this can be ignored. + */ +if (!isset($_SESSION["u_id"]) && !isset($token)) { + error("Unauthorized", 401); +} + +// Input check +if ( + (!isset($selection) && !isset($token)) || + (empty($selection) && empty($token)) || + !isset($action) || empty($action) +) { + error("Missing input! Arguments needed: (selection or token) and action", 400); +} + +/** + * Try to acquire the file ID for the provided token and assign it to $selection. + * This is a simpler approach than making sure $token exists in every step. + */ +if (isset($token)) { + if (is_string($token) && strlen($token) === 16) { + $res = $db->request("SELECT id FROM `" . $table_prefix . "files` WHERE token=?", "s", $token); + $res_array = $res->fetch_assoc(); + if (!$res_array) error("Provided token not found", 400); + $selection = array($res_array["id"]); + } else { + error("Provided token not found", 400); + } +} + +/** + * The selection can also consist of a single file ID. + * In this case, enclosing it in an array should not be necessary. + */ +if (is_string($selection)) { + $selection = array($selection); +} + +/** + * Generates questionmarks to be used in a mysqli_stmt. + * + * @param array $array The array of parameters that should be used in the statement. + * @return string e.g.: "?, ?, ?, ?, ?" + */ +function generate_q_marks($array) +{ + $len = count($array); + return (str_repeat("?, ", $len - 1) . "?"); +} + +/** + * Generates a types string as is requested in a mysqli_stmt. + * + * @param array $array The array of parameters that should be used in the statement. + * @return string e.g.: "ssssss" + */ +function generate_string_types($array) +{ + $len = count($array); + return (str_repeat("s", $len)); +} + +if ($action === "delete") { + $db->request("DELETE FROM `" . $table_prefix . "files` WHERE id IN (" . generate_q_marks($selection) . ")", generate_string_types($selection), ...$selection); + $uploads_folder = dirname(__FILE__) . "/../uploads/"; + $dir_contents = array_diff(scandir($uploads_folder), array('..', '.')); + + if (!$dir_contents) { + error("Directory empty or was not found", 500, "error.directoryNotFound"); + } + + foreach ($selection as $selected_id) { + foreach ($dir_contents as $filename) { + if (!is_dir($filename) && strpos($filename, $selected_id . ".") !== false) { + if (!unlink($uploads_folder . $filename)) { + error("File " . $filename . " could not be deleted"); + } + } + } + } +} elseif ($action === "editTitle") { + $value = $_POST->value; + + if (!isset($value) || !is_string($value)) { + error("Missing input! Arguments needed: (selection or token), action and value", 400); + } + + $req = $db->request("UPDATE `" . $table_prefix . "files` SET title=? WHERE id IN (" . generate_q_marks($selection) . ")", "s" . generate_string_types($selection), $value, ...$selection); +} else { + error("Provided action does not exist", 400); +} + +respond("Task successfully executed!"); diff --git a/public/api/get.php b/public/api/get.php new file mode 100755 index 0000000..4789be2 --- /dev/null +++ b/public/api/get.php @@ -0,0 +1,21 @@ +request_file($id); + +header("Content-type: application/json"); +echo json_encode($result); diff --git a/public/api/getAll.php b/public/api/getAll.php new file mode 100755 index 0000000..c57fe89 --- /dev/null +++ b/public/api/getAll.php @@ -0,0 +1,26 @@ +request("SELECT id, thumb_height, timestamp, title FROM `" . $table_prefix . "files` WHERE timestamp > " . $since . " ORDER BY timestamp DESC"); +} else { + $result = $db->request("SELECT id, thumb_height, timestamp, title FROM `" . $table_prefix . "files` ORDER BY timestamp DESC"); +} + +header("Content-type: application/json"); +echo json_encode($result->fetch_all(MYSQLI_ASSOC)); diff --git a/public/api/login.php b/public/api/login.php new file mode 100755 index 0000000..0b0a861 --- /dev/null +++ b/public/api/login.php @@ -0,0 +1,28 @@ +request_user("SELECT *", "username", $username); +$row = mysqli_fetch_array($result, MYSQLI_ASSOC); + +if (!password_verify($password, $row["password"])) { + error("Invalid username or password!", 401, "error.loginInvalidData"); +} + +$_SESSION["u_id"] = $row["id"]; +$_SESSION["u_name"] = $row["username"]; + +respond("Successfully logged in!"); +exit(); diff --git a/public/api/logout.php b/public/api/logout.php new file mode 100755 index 0000000..558fcc7 --- /dev/null +++ b/public/api/logout.php @@ -0,0 +1,6 @@ + 1.342e+8 || !isset($file["size"])) { + error("Either no file was provided or the size exceeded the predefined limit of the server."); +} + +// Generic upload error +if ($file["error"] > 0) { + error("An unexpected error happened, upload did not succeed. Info: " . $file["error"]); +} + +// Check if file format is supported +$fileType = $file["type"]; +if (!preg_match($supportedImageFormats, $fileType)) { + error("File type '" . $fileType . "' not supported.", 415); +} + +require_once "../protected/uploaders/image.inc.php"; +$image = new ImageUpload($file, $_POST["title"], $_POST["timestamp"]); +if ($image->upload()) { + $urls = $image->get_url_info(); +} + +header("Content-type: application/json"); +echo json_encode($urls); diff --git a/public/index.php b/public/index.php new file mode 100755 index 0000000..16e609d --- /dev/null +++ b/public/index.php @@ -0,0 +1,99 @@ +request_file($segments[0]); + $title = ($file_data["title"] !== "" ? ($file_data["title"] . " - ") : "") . $file_data["id"] . " - Shadis"; + } +} +?> + + + + + + + + + + + + + Shadis + + + $_SESSION["u_name"]); + echo ''; + } + if (!is_null($file_data)) : + $file_data["fromServer"] = true; + $origin_url = url_origin($_SERVER); + $file_url = $origin_url . "/" . $file_data["id"] . "." . $file_data["extension"]; + + echo ''; + ?> + + + + + + + "> + + "> + "> + "> + + + + + "> + + + + + + +
+ +
+ +
+ + + + \ No newline at end of file diff --git a/public/manifest.json b/public/manifest.json new file mode 100755 index 0000000..0dc9c16 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "static/media/favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "static/media/logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "static/media/logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/public/protected/config.sample.php b/public/protected/config.sample.php new file mode 100755 index 0000000..4a5749b --- /dev/null +++ b/public/protected/config.sample.php @@ -0,0 +1,57 @@ +con = $con; + } + + /** + * Executes a user-defined SQL request via mysqli_stmt. + * + * @param string $sql_stmt The SQL request in form of a statement + * @param string $types A string that contains one or more characters which specify the types for the corresponding bind variables. Refer to mysqli_smt_bind_param() + * @param mixed ...$args Matching arguments for the statement. + */ + public function request($sql_stmt, $types = null, ...$args) + { + $stmt = mysqli_stmt_init($this->con); + + if (!mysqli_stmt_prepare($stmt, $sql_stmt)) { + error(mysqli_stmt_errno($stmt) . ":" . mysqli_stmt_error($stmt)); + } + + // Checking whether stmt parameters need to be appended + if (!is_null($types)) { + mysqli_stmt_bind_param($stmt, $types, ...$args); + } + + mysqli_stmt_execute($stmt); + $result = mysqli_stmt_get_result($stmt); + + if ($result == false && mysqli_stmt_errno($stmt)) { + error(mysqli_stmt_errno($stmt) . ":" . mysqli_stmt_error($stmt)); + } + + return $result; + } + + /** + * Executes an SQL request in the users table with one column parameter. + * The request string looks as follows: $exec . " FROM " . $GLOBALS["table_prefix"] . "users WHERE " . $column . "=?" + * + * @param string $exec The operation you want to execute + * @param string $column The adressed column for the operation + * @param string $value The value of $column + */ + public function request_user($exec, $column, $value) + { + $sql = $exec . " FROM " . $GLOBALS["table_prefix"] . "users WHERE " . $column . "=?"; + $result = $this->request($sql, "s", $value); + return $result; + } + + /** + * Requests a new row in the files table + * + * @param string $uid Unique ID of the file. + * @param string $token Unique ID for editing the file. + * @param string $extension File type (Supported: gif, jpg, png). + * @param string $timestamp Timestamp of last modification + * @param string $title Image title that will be shown on the page + * @param string $width Image width + * @param string $height Image height + * @param string $thumb_height Thumbnail height + */ + public function request_upload($uid, $token, $extension, $timestamp, $title, $width, $height, $thumb_height) + { + $sql = "INSERT INTO " . $GLOBALS["table_prefix"] . "files (id, token, extension, width, height, thumb_height, timestamp, title) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + $result = $this->request($sql, "sssiisis", $uid, $token, $extension, $width, $height, $thumb_height, $timestamp, $title); + return $result; + } + + /** + * Retreives a file entry in the files table. + * + * @param string $uid Unique ID of the file. + */ + public function request_file($uid) + { + $sql = "SELECT id, width, height, thumb_height, extension, title, timestamp FROM `" . $GLOBALS["table_prefix"] . "files` WHERE id=?"; + $result = $this->request($sql, "s", $uid); + return $result->fetch_assoc(); + } +} + +$db = new db(); diff --git a/public/protected/output.inc.php b/public/protected/output.inc.php new file mode 100755 index 0000000..48f487f --- /dev/null +++ b/public/protected/output.inc.php @@ -0,0 +1,43 @@ + $code, "message" => $message, "i18n" => $i18n_id))); +} + +/** + * Dumps an error message in JSON format. + * + * @param string $message A custom error message + * @param int $code An http error code to respond with + */ +function error($message = "An unexpected error happened!", $code = 500, $i18n_id = "") +{ + respond($message, $code, $i18n_id); +} + +/** + * Returns the URL of the current server + * + * @param array $s A server array, usually $_SERVER + */ +function url_origin($s) +{ + $ssl = (!empty($s["HTTPS"]) && $s["HTTPS"] == "on"); + $sp = strtolower($s["SERVER_PROTOCOL"]); + $protocol = substr($sp, 0, strpos($sp, "/")) . (($ssl) ? "s" : ""); + $port = $s["SERVER_PORT"]; + $port = ((!$ssl && $port == "80") || ($ssl && $port == "443")) ? "" : ":" . $port; + $host = isset($s["HTTP_HOST"]) ? $s["HTTP_HOST"] : null; + $host = isset($host) ? $host : $s["SERVER_NAME"] . $port; + return $protocol . "://" . $host; +} diff --git a/public/protected/uploaders/image.inc.php b/public/protected/uploaders/image.inc.php new file mode 100755 index 0000000..9c57458 --- /dev/null +++ b/public/protected/uploaders/image.inc.php @@ -0,0 +1,216 @@ +file = $file; + $this->timestamp = $timestamp ?: time(); + $this->file_title = $title ?: ""; + $this->db = new db(); + + $this->file_id = $this->loop_through_random_string(8); + $this->file_token = $this->loop_through_random_string(16, "token"); + + $this->get_file_dimensions($this->file["tmp_name"]); + // $this->file_extension = $this->get_file_extension($file); + } + + /** + * Makes sure that the supported filetypes are correctly displayed + * e.g.: jpg is also available as jpeg + * + * @param mixed $file The file to be analysed + */ + private function get_file_extension($file) + { + $type = substr($file["type"], 6); + if ($type == "jpeg") return "jpg"; + else return $type; + } + + /** + * Generates a string with randomly selected letters and numbers + * + * @param int $length The desired length of the string + */ + private function generate_random_string($length = 8) + { + $characters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + $characters_length = strlen($characters); + $random_string = ""; + for ($i = 0; $i < $length; $i++) { + $random_string .= $characters[rand(0, $characters_length - 1)]; + } + return $random_string; + } + + /** + * Checks whether the given string is already being used in the files table + * + * @param string $column An existing column in the files table to search in + * @param string $value The string to look for + */ + private function is_string_available($column, $value) + { + $con = $this->db->con; + $result = mysqli_query($con, "SELECT id FROM `" . $GLOBALS["table_prefix"] . "files` WHERE $column='$value'"); + if (mysqli_error($con)) { + error(mysqli_errno($con) . ":" . mysqli_error($con)); + } + return $result->num_rows !== 0; + } + + /** + * Generates a randomly generated string + * and compares it to the already existing ones in the database + * + * @param int $length The desired length of the string + * @param string $column An existing column in the files table to search in + */ + private function loop_through_random_string($length = 8, $column = "id") + { + do { + $random_string = $this->generate_random_string($length); + } while ($this->is_string_available($column, $random_string)); + return $random_string; + } + + /** + * Generates a thumbnail for the image. + * The function assumes that the image has already been put to the uploads directory. + */ + private function generate_thumbnail() + { + // We need to use type from the file array directly, since jpg !== jpeg + $type = substr($this->file["type"], 6); + $image_path = $GLOBALS["base_folder"] . $this->file_id . "." . $this->file_extension; + $thumbnail_path = $GLOBALS["base_folder"] . $this->file_id . ".thumb.jpg"; + + // Using an array to make this part more readable + $exec_array = array( + $GLOBALS["imagick_path"], + "convert", + $image_path, + "-filter Triangle", + "-define " . $type . ":size=" . $this->file_width . "x" . $this->file_height, + "-thumbnail '200>'", + "-background white", + "-alpha Background", + "-unsharp 0.25x0.25+8+0.065", + "-dither None", + "-sampling-factor 4:2:0", + "-quality 82", + "-define jpeg:fancy-upsampling=off", + "-interlace none", + "-colorspace RGB", + "-strip", + $thumbnail_path, + "2>&1", + ); + + // Combining arguments into exec string + exec(implode(" ", $exec_array)); + + // Setting the thumbnail_height according to the newly generated image + $this->thumbnail_height = exec($GLOBALS["imagick_path"] . " identify -ping -format '%h' " . $thumbnail_path); + return true; + } + + /** + * Moves the uploaded file into its appropriate location + * and adds a new row to the files table + */ + public function upload() + { + $base_folder = $GLOBALS["base_folder"]; + $target_name = $base_folder . $this->file_id . "." . $this->file_extension; + + mkdir($base_folder); + if (!move_uploaded_file($this->file["tmp_name"], $target_name)) { + error("Uploading image did not succeed. Check whether the destination is writeable."); + } + + if (!$this->generate_thumbnail()) { + error("Generating image thumbnail did not succeed."); + } + + $this->db->request_upload($this->file_id, $this->file_token, $this->file_extension, $this->timestamp, $this->file_title, $this->file_width, $this->file_height, $this->thumbnail_height); + return true; + } + + /** + * Determines file dimensions in pixels + * and assignes them to the class variables "file_width" and "file_height" + * + * @param string $filename The path to the file to be analysed + * @uses getID3 + */ + private function get_file_dimensions($filename) + { + try { + require_once dirname(__FILE__) . "/../getID3/getid3.php"; + $getID3 = new getID3(); + $analyzed_data = $getID3->analyze($filename); + + try { + $this->file_extension = $analyzed_data["fileformat"]; + + switch ($this->file_extension) { + case "png": + $parent_data = $analyzed_data[$this->file_extension]["IHDR"]; + $this->file_width = $parent_data["width"]; + $this->file_height = $parent_data["height"]; + break; + case "jpg": + case "gif": + $parent_data = $analyzed_data["video"]; + $this->file_width = $parent_data["resolution_x"]; + $this->file_height = $parent_data["resolution_y"]; + break; + } + } catch (ErrorException $ee) { + if (isset($analyzed_data["error"])) { + error("An error happened while using getID3: " . json_encode($analyzed_data["error"])); + } else { + error("An unknown error happened while trying to get the file dimensions: " . $ee); + } + } + } catch (ErrorException $th) { + error("An error happened while trying to set up getID3: " . $th); + } + } + + /** + * Returns an array with useful URLs of the uploaded file + * + * @return string[] Available keys: "id", "file_url", "file_editable_url", "file_direct_url", "file_delete_url" + */ + public function get_url_info() + { + $base_url = dirname(dirname(url_origin($_SERVER) . $_SERVER["REQUEST_URI"])) . "/"; + return array( + "id" => $this->file_id, + "file_url" => $base_url . $this->file_id . "/", + "file_editable_url" => $base_url . $this->file_id . "/?token=" . $this->file_token, + "file_direct_url" => $base_url . $this->file_id . "." . $this->file_extension, + "file_delete_url" => $base_url . "api/edit.php?action=delete&token=" . $this->file_token + ); + } +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100755 index 0000000..2d1aa03 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: / \ No newline at end of file diff --git a/public/static/locales/de/common.json b/public/static/locales/de/common.json new file mode 100755 index 0000000..d90f923 --- /dev/null +++ b/public/static/locales/de/common.json @@ -0,0 +1,27 @@ +{ + "loginButton": "Anmelden", + "username": "Benutzername", + "password": "Passwort", + "goto": "Gehe zu {{name}}", + "error": { + "loginGeneric": "Anmeldung fehlgeschlagen", + "loginMissingData": "Benutzername oder Passwort fehlt!", + "loginInvalidData": "Ungültiger Benutzername oder Passwort!", + "requestFile": "Seite \"{{id}}\" konnte nicht abgerufen werden!", + "requestGeneric": "Ausführung der Anfrage ist fehlgeschlagen!" + }, + "notification": { + "error": "Oh nein!", + "warning": "Warnung:", + "info": "Info:" + }, + "hyperlinks": { + "about": "Über Shadis", + "thirdParty": "Drittanbieterinfos", + "changeLanguage": "Sprache Ändern" + }, + "notFound": { + "title": "Hier gibt's nichts zu sehen!", + "description": "Diese Seite wurde entweder entfernt oder hat nie jemals existiert." + } +} diff --git a/public/static/locales/de/dashboard.json b/public/static/locales/de/dashboard.json new file mode 100755 index 0000000..fb8955b --- /dev/null +++ b/public/static/locales/de/dashboard.json @@ -0,0 +1,29 @@ +{ + "emptyTitle": "Du hast noch nichts hochgeladen!", + "emptyDescription": "Du kannst hier ein Bild per Drag & Drop hochladen.", + "untitled": "Unbennantes Bild", + "delete": "Entfernen", + "deleteSelected": "Ausgewählte Datei entfernen", + "deleteSelected_plural": "Ausgewählte Dateien entfernen", + "selectedDeleted": "Eine Datei erfolgreich entfernt", + "selectedDeleted_plural": "{{count}} Dateien erfolgreich entfernt", + "upload": { + "title": "Dateien hochladen", + "select": "Datei auswählen", + "finished": "Datei als {{filename}} hochgeladen", + "visit": "Dateiverknüpfung aufsuchen", + "dropTitle": "Drop it like it's hot!", + "dropDescription": "Du kannst eine Datei hochladen, indem du sie in dieses Fenster hineinziehst.", + "error": { + "title": "Oh nein, etwas ist schiefgelaufen!", + "unsupported": "Dieses Dateiformat wird nicht unterstützt.\nFolgende sind erlaubt: jpg, png, gif" + }, + "aria": { + "dialogLabel": "Hochlade-Dialog" + } + }, + "error": { + "requestGeneric": "Ausführung der Anfrage ist fehlgeschlagen", + "listGeneric": "Abrufung der Dateienliste ist fehlgeschlagen" + } +} diff --git a/public/static/locales/de/fileview.json b/public/static/locales/de/fileview.json new file mode 100755 index 0000000..8a4ae0d --- /dev/null +++ b/public/static/locales/de/fileview.json @@ -0,0 +1,12 @@ +{ + "inspector": "Datei-Inspektor", + "description": "Beschreibung", + "width": "Breite", + "height": "Höhe", + "uploaded": "Hochgeladen", + "format": "Dateiformat", + "size": "Dateiengröße", + "untitled": "-", + "download": "Herunterladen", + "source": "Original öffnen" +} diff --git a/public/static/locales/en/common.json b/public/static/locales/en/common.json new file mode 100755 index 0000000..82ceeb5 --- /dev/null +++ b/public/static/locales/en/common.json @@ -0,0 +1,27 @@ +{ + "loginButton": "Log in", + "username": "Username", + "password": "Password", + "goto": "Go to page {{name}}", + "error": { + "loginGeneric": "Couldn't log you in", + "loginMissingData": "Username or password missing!", + "loginInvalidData": "Invalid username or password!", + "requestFile": "Can't access page \"{{id}}\"!", + "requestGeneric": "Couldn't execute request" + }, + "notification": { + "error": "Uh-Oh:", + "warning": "Warning:", + "info": "Info:" + }, + "hyperlinks": { + "about": "About", + "thirdParty": "Third-Party Notices", + "changeLanguage": "Change Language" + }, + "notFound": { + "title": "Nothing to see here!", + "description": "This page has either been removed or never existed before." + } +} diff --git a/public/static/locales/en/dashboard.json b/public/static/locales/en/dashboard.json new file mode 100755 index 0000000..262dc9d --- /dev/null +++ b/public/static/locales/en/dashboard.json @@ -0,0 +1,29 @@ +{ + "emptyTitle": "You haven't uploaded anything yet!", + "emptyDescription": "You can drag-and-drop an image into here in order to upload it.", + "untitled": "Untitled Image", + "delete": "Delete File", + "deleteSelected": "Delete selected item", + "deleteSelected_plural": "Delete selected items", + "selectedDeleted": "Successfully deleted {{count}} item", + "selectedDeleted_plural": "Successfully deleted {{count}} items", + "upload": { + "title": "Upload a file", + "select": "Select File", + "finished": "File uploaded as {{filename}}", + "visit": "Open file page", + "dropTitle": "Drop it like it's hot!", + "dropDescription": "You can upload an image by dragging it into here.", + "error": { + "title": "Uh-Oh, an error happened!", + "unsupported": "This filetype cannot be uploaded.\nSupported types are: jpg, png, gif" + }, + "aria": { + "dialogLabel": "Upload dialog" + } + }, + "error": { + "requestGeneric": "Couldn't execute request", + "listGeneric": "Couldn't retrieve file list" + } +} diff --git a/public/static/locales/en/fileview.json b/public/static/locales/en/fileview.json new file mode 100755 index 0000000..ad6c6e7 --- /dev/null +++ b/public/static/locales/en/fileview.json @@ -0,0 +1,12 @@ +{ + "inspector": "File Inspector", + "description": "Description", + "width": "Width", + "height": "Height", + "uploaded": "Uploaded", + "format": "File format", + "size": "Size", + "untitled": "-", + "download": "Download File", + "source": "View Original" +} diff --git a/public/static/media/favicon.ico b/public/static/media/favicon.ico new file mode 100755 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 zcma);c{J4h9>;%nil|2-o+rCuEF-(I%-F}ijC~o(k~HKAkr0)!FCj~d>`RtpD?8b; zXOC1OD!V*IsqUwzbMF1)-gEDD=A573Z-&G7^LoAC9|WO7Xc0Cx1g^Zu0u_SjAPB3vGa^W|sj)80f#V0@M_CAZTIO(t--xg= z!sii`1giyH7EKL_+Wi0ab<)&E_0KD!3Rp2^HNB*K2@PHCs4PWSA32*-^7d{9nH2_E zmC{C*N*)(vEF1_aMamw2A{ZH5aIDqiabnFdJ|y0%aS|64E$`s2ccV~3lR!u<){eS` z#^Mx6o(iP1Ix%4dv`t@!&Za-K@mTm#vadc{0aWDV*_%EiGK7qMC_(`exc>-$Gb9~W!w_^{*pYRm~G zBN{nA;cm^w$VWg1O^^<6vY`1XCD|s_zv*g*5&V#wv&s#h$xlUilPe4U@I&UXZbL z0)%9Uj&@yd03n;!7do+bfixH^FeZ-Ema}s;DQX2gY+7g0s(9;`8GyvPY1*vxiF&|w z>!vA~GA<~JUqH}d;DfBSi^IT*#lrzXl$fNpq0_T1tA+`A$1?(gLb?e#0>UELvljtQ zK+*74m0jn&)5yk8mLBv;=@}c{t0ztT<v;Avck$S6D`Z)^c0(jiwKhQsn|LDRY&w(Fmi91I7H6S;b0XM{e zXp0~(T@k_r-!jkLwd1_Vre^v$G4|kh4}=Gi?$AaJ)3I+^m|Zyj#*?Kp@w(lQdJZf4 z#|IJW5z+S^e9@(6hW6N~{pj8|NO*>1)E=%?nNUAkmv~OY&ZV;m-%?pQ_11)hAr0oAwILrlsGawpxx4D43J&K=n+p3WLnlDsQ$b(9+4 z?mO^hmV^F8MV{4Lx>(Q=aHhQ1){0d*(e&s%G=i5rq3;t{JC zmgbn5Nkl)t@fPH$v;af26lyhH!k+#}_&aBK4baYPbZy$5aFx4}ka&qxl z$=Rh$W;U)>-=S-0=?7FH9dUAd2(q#4TCAHky!$^~;Dz^j|8_wuKc*YzfdAht@Q&ror?91Dm!N03=4=O!a)I*0q~p0g$Fm$pmr$ zb;wD;STDIi$@M%y1>p&_>%?UP($15gou_ue1u0!4(%81;qcIW8NyxFEvXpiJ|H4wz z*mFT(qVx1FKufG11hByuX%lPk4t#WZ{>8ka2efjY`~;AL6vWyQKpJun2nRiZYDij$ zP>4jQXPaP$UC$yIVgGa)jDV;F0l^n(V=HMRB5)20V7&r$jmk{UUIe zVjKroK}JAbD>B`2cwNQ&GDLx8{pg`7hbA~grk|W6LgiZ`8y`{Iq0i>t!3p2}MS6S+ zO_ruKyAElt)rdS>CtF7j{&6rP-#c=7evGMt7B6`7HG|-(WL`bDUAjyn+k$mx$CH;q2Dz4x;cPP$hW=`pFfLO)!jaCL@V2+F)So3}vg|%O*^T1j>C2lx zsURO-zIJC$^$g2byVbRIo^w>UxK}74^TqUiRR#7s_X$e)$6iYG1(PcW7un-va-S&u zHk9-6Zn&>T==A)lM^D~bk{&rFzCi35>UR!ZjQkdSiNX*-;l4z9j*7|q`TBl~Au`5& z+c)*8?#-tgUR$Zd%Q3bs96w6k7q@#tUn`5rj+r@_sAVVLqco|6O{ILX&U-&-cbVa3 zY?ngHR@%l{;`ri%H*0EhBWrGjv!LE4db?HEWb5mu*t@{kv|XwK8?npOshmzf=vZA@ zVSN9sL~!sn?r(AK)Q7Jk2(|M67Uy3I{eRy z_l&Y@A>;vjkWN5I2xvFFTLX0i+`{qz7C_@bo`ZUzDugfq4+>a3?1v%)O+YTd6@Ul7 zAfLfm=nhZ`)P~&v90$&UcF+yXm9sq!qCx3^9gzIcO|Y(js^Fj)Rvq>nQAHI92ap=P z10A4@prk+AGWCb`2)dQYFuR$|H6iDE8p}9a?#nV2}LBCoCf(Xi2@szia7#gY>b|l!-U`c}@ zLdhvQjc!BdLJvYvzzzngnw51yRYCqh4}$oRCy-z|v3Hc*d|?^Wj=l~18*E~*cR_kU z{XsxM1i{V*4GujHQ3DBpl2w4FgFR48Nma@HPgnyKoIEY-MqmMeY=I<%oG~l!f<+FN z1ZY^;10j4M4#HYXP zw5eJpA_y(>uLQ~OucgxDLuf}fVs272FaMxhn4xnDGIyLXnw>Xsd^J8XhcWIwIoQ9} z%FoSJTAGW(SRGwJwb=@pY7r$uQRK3Zd~XbxU)ts!4XsJrCycrWSI?e!IqwqIR8+Jh zlRjZ`UO1I!BtJR_2~7AbkbSm%XQqxEPkz6BTGWx8e}nQ=w7bZ|eVP4?*Tb!$(R)iC z9)&%bS*u(lXqzitAN)Oo=&Ytn>%Hzjc<5liuPi>zC_nw;Z0AE3Y$Jao_Q90R-gl~5 z_xAb2J%eArrC1CN4G$}-zVvCqF1;H;abAu6G*+PDHSYFx@Tdbfox*uEd3}BUyYY-l zTfEsOqsi#f9^FoLO;ChK<554qkri&Av~SIM*{fEYRE?vH7pTAOmu2pz3X?Wn*!ROX ztd54huAk&mFBemMooL33RV-*1f0Q3_(7hl$<#*|WF9P!;r;4_+X~k~uKEqdzZ$5Al zV63XN@)j$FN#cCD;ek1R#l zv%pGrhB~KWgoCj%GT?%{@@o(AJGt*PG#l3i>lhmb_twKH^EYvacVY-6bsCl5*^~L0 zonm@lk2UvvTKr2RS%}T>^~EYqdL1q4nD%0n&Xqr^cK^`J5W;lRRB^R-O8b&HENO||mo0xaD+S=I8RTlIfVgqN@SXDr2&-)we--K7w= zJVU8?Z+7k9dy;s;^gDkQa`0nz6N{T?(A&Iz)2!DEecLyRa&FI!id#5Z7B*O2=PsR0 zEvc|8{NS^)!d)MDX(97Xw}m&kEO@5jqRaDZ!+%`wYOI<23q|&js`&o4xvjP7D_xv@ z5hEwpsp{HezI9!~6O{~)lLR@oF7?J7i>1|5a~UuoN=q&6N}EJPV_GD`&M*v8Y`^2j zKII*d_@Fi$+i*YEW+Hbzn{iQk~yP z>7N{S4)r*!NwQ`(qcN#8SRQsNK6>{)X12nbF`*7#ecO7I)Q$uZsV+xS4E7aUn+U(K baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/public/static/media/logo192.png b/public/static/media/logo192.png new file mode 100755 index 0000000000000000000000000000000000000000..fc44b0a3796c0e0a64c3d858ca038bd4570465d9 GIT binary patch literal 5347 zcmZWtbyO6NvR-oO24RV%BvuJ&=?+<7=`LvyB&A_#M7mSDYw1v6DJkiYl9XjT!%$dLEBTQ8R9|wd3008in6lFF3GV-6mLi?MoP_y~}QUnaDCHI#t z7w^m$@6DI)|C8_jrT?q=f8D?0AM?L)Z}xAo^e^W>t$*Y0KlT5=@bBjT9kxb%-KNdk zeOS1tKO#ChhG7%{ApNBzE2ZVNcxbrin#E1TiAw#BlUhXllzhN$qWez5l;h+t^q#Eav8PhR2|T}y5kkflaK`ba-eoE+Z2q@o6P$)=&` z+(8}+-McnNO>e#$Rr{32ngsZIAX>GH??tqgwUuUz6kjns|LjsB37zUEWd|(&O!)DY zQLrq%Y>)Y8G`yYbYCx&aVHi@-vZ3|ebG!f$sTQqMgi0hWRJ^Wc+Ibv!udh_r%2|U) zPi|E^PK?UE!>_4`f`1k4hqqj_$+d!EB_#IYt;f9)fBOumGNyglU(ofY`yHq4Y?B%- zp&G!MRY<~ajTgIHErMe(Z8JG*;D-PJhd@RX@QatggM7+G(Lz8eZ;73)72Hfx5KDOE zkT(m}i2;@X2AT5fW?qVp?@WgN$aT+f_6eo?IsLh;jscNRp|8H}Z9p_UBO^SJXpZew zEK8fz|0Th%(Wr|KZBGTM4yxkA5CFdAj8=QSrT$fKW#tweUFqr0TZ9D~a5lF{)%-tTGMK^2tz(y2v$i%V8XAxIywrZCp=)83p(zIk6@S5AWl|Oa2hF`~~^W zI;KeOSkw1O#TiQ8;U7OPXjZM|KrnN}9arP)m0v$c|L)lF`j_rpG(zW1Qjv$=^|p*f z>)Na{D&>n`jOWMwB^TM}slgTEcjxTlUby89j1)|6ydRfWERn3|7Zd2&e7?!K&5G$x z`5U3uFtn4~SZq|LjFVrz$3iln-+ucY4q$BC{CSm7Xe5c1J<=%Oagztj{ifpaZk_bQ z9Sb-LaQMKp-qJA*bP6DzgE3`}*i1o3GKmo2pn@dj0;He}F=BgINo};6gQF8!n0ULZ zL>kC0nPSFzlcB7p41doao2F7%6IUTi_+!L`MM4o*#Y#0v~WiO8uSeAUNp=vA2KaR&=jNR2iVwG>7t%sG2x_~yXzY)7K& zk3p+O0AFZ1eu^T3s};B%6TpJ6h-Y%B^*zT&SN7C=N;g|#dGIVMSOru3iv^SvO>h4M=t-N1GSLLDqVTcgurco6)3&XpU!FP6Hlrmj}f$ zp95;b)>M~`kxuZF3r~a!rMf4|&1=uMG$;h^g=Kl;H&Np-(pFT9FF@++MMEx3RBsK?AU0fPk-#mdR)Wdkj)`>ZMl#^<80kM87VvsI3r_c@_vX=fdQ`_9-d(xiI z4K;1y1TiPj_RPh*SpDI7U~^QQ?%0&!$Sh#?x_@;ag)P}ZkAik{_WPB4rHyW#%>|Gs zdbhyt=qQPA7`?h2_8T;-E6HI#im9K>au*(j4;kzwMSLgo6u*}-K`$_Gzgu&XE)udQ zmQ72^eZd|vzI)~!20JV-v-T|<4@7ruqrj|o4=JJPlybwMg;M$Ud7>h6g()CT@wXm` zbq=A(t;RJ^{Xxi*Ff~!|3!-l_PS{AyNAU~t{h;(N(PXMEf^R(B+ZVX3 z8y0;0A8hJYp@g+c*`>eTA|3Tgv9U8#BDTO9@a@gVMDxr(fVaEqL1tl?md{v^j8aUv zm&%PX4^|rX|?E4^CkplWWNv*OKM>DxPa z!RJ)U^0-WJMi)Ksc!^ixOtw^egoAZZ2Cg;X7(5xZG7yL_;UJ#yp*ZD-;I^Z9qkP`} zwCTs0*%rIVF1sgLervtnUo&brwz?6?PXRuOCS*JI-WL6GKy7-~yi0giTEMmDs_-UX zo=+nFrW_EfTg>oY72_4Z0*uG>MnXP=c0VpT&*|rvv1iStW;*^={rP1y?Hv+6R6bxFMkxpWkJ>m7Ba{>zc_q zEefC3jsXdyS5??Mz7IET$Kft|EMNJIv7Ny8ZOcKnzf`K5Cd)&`-fTY#W&jnV0l2vt z?Gqhic}l}mCv1yUEy$%DP}4AN;36$=7aNI^*AzV(eYGeJ(Px-j<^gSDp5dBAv2#?; zcMXv#aj>%;MiG^q^$0MSg-(uTl!xm49dH!{X0){Ew7ThWV~Gtj7h%ZD zVN-R-^7Cf0VH!8O)uUHPL2mO2tmE*cecwQv_5CzWeh)ykX8r5Hi`ehYo)d{Jnh&3p z9ndXT$OW51#H5cFKa76c<%nNkP~FU93b5h-|Cb}ScHs@4Q#|}byWg;KDMJ#|l zE=MKD*F@HDBcX@~QJH%56eh~jfPO-uKm}~t7VkHxHT;)4sd+?Wc4* z>CyR*{w@4(gnYRdFq=^(#-ytb^5ESD?x<0Skhb%Pt?npNW1m+Nv`tr9+qN<3H1f<% zZvNEqyK5FgPsQ`QIu9P0x_}wJR~^CotL|n zk?dn;tLRw9jJTur4uWoX6iMm914f0AJfB@C74a;_qRrAP4E7l890P&{v<}>_&GLrW z)klculcg`?zJO~4;BBAa=POU%aN|pmZJn2{hA!d!*lwO%YSIzv8bTJ}=nhC^n}g(ld^rn#kq9Z3)z`k9lvV>y#!F4e{5c$tnr9M{V)0m(Z< z#88vX6-AW7T2UUwW`g<;8I$Jb!R%z@rCcGT)-2k7&x9kZZT66}Ztid~6t0jKb&9mm zpa}LCb`bz`{MzpZR#E*QuBiZXI#<`5qxx=&LMr-UUf~@dRk}YI2hbMsAMWOmDzYtm zjof16D=mc`^B$+_bCG$$@R0t;e?~UkF?7<(vkb70*EQB1rfUWXh$j)R2)+dNAH5%R zEBs^?N;UMdy}V};59Gu#0$q53$}|+q7CIGg_w_WlvE}AdqoS<7DY1LWS9?TrfmcvT zaypmplwn=P4;a8-%l^e?f`OpGb}%(_mFsL&GywhyN(-VROj`4~V~9bGv%UhcA|YW% zs{;nh@aDX11y^HOFXB$a7#Sr3cEtNd4eLm@Y#fc&j)TGvbbMwze zXtekX_wJqxe4NhuW$r}cNy|L{V=t#$%SuWEW)YZTH|!iT79k#?632OFse{+BT_gau zJwQcbH{b}dzKO?^dV&3nTILYlGw{27UJ72ZN){BILd_HV_s$WfI2DC<9LIHFmtyw? zQ;?MuK7g%Ym+4e^W#5}WDLpko%jPOC=aN)3!=8)s#Rnercak&b3ESRX3z{xfKBF8L z5%CGkFmGO@x?_mPGlpEej!3!AMddChabyf~nJNZxx!D&{@xEb!TDyvqSj%Y5@A{}9 zRzoBn0?x}=krh{ok3Nn%e)#~uh;6jpezhA)ySb^b#E>73e*frBFu6IZ^D7Ii&rsiU z%jzygxT-n*joJpY4o&8UXr2s%j^Q{?e-voloX`4DQyEK+DmrZh8A$)iWL#NO9+Y@!sO2f@rI!@jN@>HOA< z?q2l{^%mY*PNx2FoX+A7X3N}(RV$B`g&N=e0uvAvEN1W^{*W?zT1i#fxuw10%~))J zjx#gxoVlXREWZf4hRkgdHx5V_S*;p-y%JtGgQ4}lnA~MBz-AFdxUxU1RIT$`sal|X zPB6sEVRjGbXIP0U+?rT|y5+ev&OMX*5C$n2SBPZr`jqzrmpVrNciR0e*Wm?fK6DY& zl(XQZ60yWXV-|Ps!A{EF;=_z(YAF=T(-MkJXUoX zI{UMQDAV2}Ya?EisdEW;@pE6dt;j0fg5oT2dxCi{wqWJ<)|SR6fxX~5CzblPGr8cb zUBVJ2CQd~3L?7yfTpLNbt)He1D>*KXI^GK%<`bq^cUq$Q@uJifG>p3LU(!H=C)aEL zenk7pVg}0{dKU}&l)Y2Y2eFMdS(JS0}oZUuVaf2+K*YFNGHB`^YGcIpnBlMhO7d4@vV zv(@N}(k#REdul8~fP+^F@ky*wt@~&|(&&meNO>rKDEnB{ykAZ}k>e@lad7to>Ao$B zz<1(L=#J*u4_LB=8w+*{KFK^u00NAmeNN7pr+Pf+N*Zl^dO{LM-hMHyP6N!~`24jd zXYP|Ze;dRXKdF2iJG$U{k=S86l@pytLx}$JFFs8e)*Vi?aVBtGJ3JZUj!~c{(rw5>vuRF$`^p!P8w1B=O!skwkO5yd4_XuG^QVF z`-r5K7(IPSiKQ2|U9+`@Js!g6sfJwAHVd|s?|mnC*q zp|B|z)(8+mxXyxQ{8Pg3F4|tdpgZZSoU4P&9I8)nHo1@)9_9u&NcT^FI)6|hsAZFk zZ+arl&@*>RXBf-OZxhZerOr&dN5LW9@gV=oGFbK*J+m#R-|e6(Loz(;g@T^*oO)0R zN`N=X46b{7yk5FZGr#5&n1!-@j@g02g|X>MOpF3#IjZ_4wg{dX+G9eqS+Es9@6nC7 zD9$NuVJI}6ZlwtUm5cCAiYv0(Yi{%eH+}t)!E^>^KxB5^L~a`4%1~5q6h>d;paC9c zTj0wTCKrhWf+F#5>EgX`sl%POl?oyCq0(w0xoL?L%)|Q7d|Hl92rUYAU#lc**I&^6p=4lNQPa0 znQ|A~i0ip@`B=FW-Q;zh?-wF;Wl5!+q3GXDu-x&}$gUO)NoO7^$BeEIrd~1Dh{Tr` z8s<(Bn@gZ(mkIGnmYh_ehXnq78QL$pNDi)|QcT*|GtS%nz1uKE+E{7jdEBp%h0}%r zD2|KmYGiPa4;md-t_m5YDz#c*oV_FqXd85d@eub?9N61QuYcb3CnVWpM(D-^|CmkL z(F}L&N7qhL2PCq)fRh}XO@U`Yn<?TNGR4L(mF7#4u29{i~@k;pLsgl({YW5`Mo+p=zZn3L*4{JU;++dG9 X@eDJUQo;Ye2mwlRs?y0|+_a0zY+Zo%Dkae}+MySoIppb75o?vUW_?)>@g{U2`ERQIXV zeY$JrWnMZ$QC<=ii4X|@0H8`si75jB(ElJb00HAB%>SlLR{!zO|C9P3zxw_U8?1d8uRZ=({Ga4shyN}3 zAK}WA(ds|``G4jA)9}Bt2Hy0+f3rV1E6b|@?hpGA=PI&r8)ah|)I2s(P5Ic*Ndhn^ z*T&j@gbCTv7+8rpYbR^Ty}1AY)YH;p!m948r#%7x^Z@_-w{pDl|1S4`EM3n_PaXvK z1JF)E3qy$qTj5Xs{jU9k=y%SQ0>8E$;x?p9ayU0bZZeo{5Z@&FKX>}s!0+^>C^D#z z>xsCPvxD3Z=dP}TTOSJhNTPyVt14VCQ9MQFN`rn!c&_p?&4<5_PGm4a;WS&1(!qKE z_H$;dDdiPQ!F_gsN`2>`X}$I=B;={R8%L~`>RyKcS$72ai$!2>d(YkciA^J0@X%G4 z4cu!%Ps~2JuJ8ex`&;Fa0NQOq_nDZ&X;^A=oc1&f#3P1(!5il>6?uK4QpEG8z0Rhu zvBJ+A9RV?z%v?!$=(vcH?*;vRs*+PPbOQ3cdPr5=tOcLqmfx@#hOqX0iN)wTTO21jH<>jpmwRIAGw7`a|sl?9y9zRBh>(_%| zF?h|P7}~RKj?HR+q|4U`CjRmV-$mLW>MScKnNXiv{vD3&2@*u)-6P@h0A`eeZ7}71 zK(w%@R<4lLt`O7fs1E)$5iGb~fPfJ?WxhY7c3Q>T-w#wT&zW522pH-B%r5v#5y^CF zcC30Se|`D2mY$hAlIULL%-PNXgbbpRHgn<&X3N9W!@BUk@9g*P5mz-YnZBb*-$zMM z7Qq}ic0mR8n{^L|=+diODdV}Q!gwr?y+2m=3HWwMq4z)DqYVg0J~^}-%7rMR@S1;9 z7GFj6K}i32X;3*$SmzB&HW{PJ55kT+EI#SsZf}bD7nW^Haf}_gXciYKX{QBxIPSx2Ma? zHQqgzZq!_{&zg{yxqv3xq8YV+`S}F6A>Gtl39_m;K4dA{pP$BW0oIXJ>jEQ!2V3A2 zdpoTxG&V=(?^q?ZTj2ZUpDUdMb)T?E$}CI>r@}PFPWD9@*%V6;4Ag>D#h>!s)=$0R zRXvdkZ%|c}ubej`jl?cS$onl9Tw52rBKT)kgyw~Xy%z62Lr%V6Y=f?2)J|bZJ5(Wx zmji`O;_B+*X@qe-#~`HFP<{8$w@z4@&`q^Q-Zk8JG3>WalhnW1cvnoVw>*R@c&|o8 zZ%w!{Z+MHeZ*OE4v*otkZqz11*s!#s^Gq>+o`8Z5 z^i-qzJLJh9!W-;SmFkR8HEZJWiXk$40i6)7 zZpr=k2lp}SasbM*Nbn3j$sn0;rUI;%EDbi7T1ZI4qL6PNNM2Y%6{LMIKW+FY_yF3) zSKQ2QSujzNMSL2r&bYs`|i2Dnn z=>}c0>a}>|uT!IiMOA~pVT~R@bGlm}Edf}Kq0?*Af6#mW9f9!}RjW7om0c9Qlp;yK z)=XQs(|6GCadQbWIhYF=rf{Y)sj%^Id-ARO0=O^Ad;Ph+ z0?$eE1xhH?{T$QI>0JP75`r)U_$#%K1^BQ8z#uciKf(C701&RyLQWBUp*Q7eyn76} z6JHpC9}R$J#(R0cDCkXoFSp;j6{x{b&0yE@P7{;pCEpKjS(+1RQy38`=&Yxo%F=3y zCPeefABp34U-s?WmU#JJw23dcC{sPPFc2#J$ZgEN%zod}J~8dLm*fx9f6SpO zn^Ww3bt9-r0XaT2a@Wpw;C23XM}7_14#%QpubrIw5aZtP+CqIFmsG4`Cm6rfxl9n5 z7=r2C-+lM2AB9X0T_`?EW&Byv&K?HS4QLoylJ|OAF z`8atBNTzJ&AQ!>sOo$?^0xj~D(;kS$`9zbEGd>f6r`NC3X`tX)sWgWUUOQ7w=$TO&*j;=u%25ay-%>3@81tGe^_z*C7pb9y*Ed^H3t$BIKH2o+olp#$q;)_ zfpjCb_^VFg5fU~K)nf*d*r@BCC>UZ!0&b?AGk_jTPXaSnCuW110wjHPPe^9R^;jo3 zwvzTl)C`Zl5}O2}3lec=hZ*$JnkW#7enKKc)(pM${_$9Hc=Sr_A9Biwe*Y=T?~1CK z6eZ9uPICjy-sMGbZl$yQmpB&`ouS8v{58__t0$JP%i3R&%QR3ianbZqDs<2#5FdN@n5bCn^ZtH992~5k(eA|8|@G9u`wdn7bnpg|@{m z^d6Y`*$Zf2Xr&|g%sai#5}Syvv(>Jnx&EM7-|Jr7!M~zdAyjt*xl;OLhvW-a%H1m0 z*x5*nb=R5u><7lyVpNAR?q@1U59 zO+)QWwL8t zyip?u_nI+K$uh{y)~}qj?(w0&=SE^8`_WMM zTybjG=999h38Yes7}-4*LJ7H)UE8{mE(6;8voE+TYY%33A>S6`G_95^5QHNTo_;Ao ztIQIZ_}49%{8|=O;isBZ?=7kfdF8_@azfoTd+hEJKWE!)$)N%HIe2cplaK`ry#=pV z0q{9w-`i0h@!R8K3GC{ivt{70IWG`EP|(1g7i_Q<>aEAT{5(yD z=!O?kq61VegV+st@XCw475j6vS)_z@efuqQgHQR1T4;|-#OLZNQJPV4k$AX1Uk8Lm z{N*b*ia=I+MB}kWpupJ~>!C@xEN#Wa7V+7{m4j8c?)ChV=D?o~sjT?0C_AQ7B-vxqX30s0I_`2$in86#`mAsT-w?j{&AL@B3$;P z31G4(lV|b}uSDCIrjk+M1R!X7s4Aabn<)zpgT}#gE|mIvV38^ODy@<&yflpCwS#fRf9ZX3lPV_?8@C5)A;T zqmouFLFk;qIs4rA=hh=GL~sCFsXHsqO6_y~*AFt939UYVBSx1s(=Kb&5;j7cSowdE;7()CC2|-i9Zz+_BIw8#ll~-tyH?F3{%`QCsYa*b#s*9iCc`1P1oC26?`g<9))EJ3%xz+O!B3 zZ7$j~To)C@PquR>a1+Dh>-a%IvH_Y7^ys|4o?E%3`I&ADXfC8++hAdZfzIT#%C+Jz z1lU~K_vAm0m8Qk}K$F>|>RPK%<1SI0(G+8q~H zAsjezyP+u!Se4q3GW)`h`NPSRlMoBjCzNPesWJwVTY!o@G8=(6I%4XHGaSiS3MEBK zhgGFv6Jc>L$4jVE!I?TQuwvz_%CyO!bLh94nqK11C2W$*aa2ueGopG8DnBICVUORP zgytv#)49fVXDaR$SukloYC3u7#5H)}1K21=?DKj^U)8G;MS)&Op)g^zR2($<>C*zW z;X7`hLxiIO#J`ANdyAOJle4V%ppa*(+0i3w;8i*BA_;u8gOO6)MY`ueq7stBMJTB; z-a0R>hT*}>z|Gg}@^zDL1MrH+2hsR8 zHc}*9IvuQC^Ju)^#Y{fOr(96rQNPNhxc;mH@W*m206>Lo<*SaaH?~8zg&f&%YiOEG zGiz?*CP>Bci}!WiS=zj#K5I}>DtpregpP_tfZtPa(N<%vo^#WCQ5BTv0vr%Z{)0q+ z)RbfHktUm|lg&U3YM%lMUM(fu}i#kjX9h>GYctkx9Mt_8{@s%!K_EI zScgwy6%_fR?CGJQtmgNAj^h9B#zmaMDWgH55pGuY1Gv7D z;8Psm(vEPiwn#MgJYu4Ty9D|h!?Rj0ddE|&L3S{IP%H4^N!m`60ZwZw^;eg4sk6K{ ziA^`Sbl_4~f&Oo%n;8Ye(tiAdlZKI!Z=|j$5hS|D$bDJ}p{gh$KN&JZYLUjv4h{NY zBJ>X9z!xfDGY z+oh_Z&_e#Q(-}>ssZfm=j$D&4W4FNy&-kAO1~#3Im;F)Nwe{(*75(p=P^VI?X0GFakfh+X-px4a%Uw@fSbmp9hM1_~R>?Z8+ ziy|e9>8V*`OP}4x5JjdWp}7eX;lVxp5qS}0YZek;SNmm7tEeSF*-dI)6U-A%m6YvCgM(}_=k#a6o^%-K4{`B1+}O4x zztDT%hVb;v#?j`lTvlFQ3aV#zkX=7;YFLS$uIzb0E3lozs5`Xy zi~vF+%{z9uLjKvKPhP%x5f~7-Gj+%5N`%^=yk*Qn{`> z;xj&ROY6g`iy2a@{O)V(jk&8#hHACVDXey5a+KDod_Z&}kHM}xt7}Md@pil{2x7E~ zL$k^d2@Ec2XskjrN+IILw;#7((abu;OJii&v3?60x>d_Ma(onIPtcVnX@ELF0aL?T zSmWiL3(dOFkt!x=1O!_0n(cAzZW+3nHJ{2S>tgSK?~cFha^y(l@-Mr2W$%MN{#af8J;V*>hdq!gx=d0h$T7l}>91Wh07)9CTX zh2_ZdQCyFOQ)l(}gft0UZG`Sh2`x-w`5vC2UD}lZs*5 zG76$akzn}Xi))L3oGJ75#pcN=cX3!=57$Ha=hQ2^lwdyU#a}4JJOz6ddR%zae%#4& za)bFj)z=YQela(F#Y|Q#dp}PJghITwXouVaMq$BM?K%cXn9^Y@g43$=O)F&ZlOUom zJiad#dea;-eywBA@e&D6Pdso1?2^(pXiN91?jvcaUyYoKUmvl5G9e$W!okWe*@a<^ z8cQQ6cNSf+UPDx%?_G4aIiybZHHagF{;IcD(dPO!#=u zWfqLcPc^+7Uu#l(Bpxft{*4lv#*u7X9AOzDO z1D9?^jIo}?%iz(_dwLa{ex#T}76ZfN_Z-hwpus9y+4xaUu9cX}&P{XrZVWE{1^0yw zO;YhLEW!pJcbCt3L8~a7>jsaN{V3>tz6_7`&pi%GxZ=V3?3K^U+*ryLSb)8^IblJ0 zSRLNDvIxt)S}g30?s_3NX>F?NKIGrG_zB9@Z>uSW3k2es_H2kU;Rnn%j5qP)!XHKE zPB2mHP~tLCg4K_vH$xv`HbRsJwbZMUV(t=ez;Ec(vyHH)FbfLg`c61I$W_uBB>i^r z&{_P;369-&>23R%qNIULe=1~T$(DA`ev*EWZ6j(B$(te}x1WvmIll21zvygkS%vwG zzkR6Z#RKA2!z!C%M!O>!=Gr0(J0FP=-MN=5t-Ir)of50y10W}j`GtRCsXBakrKtG& zazmITDJMA0C51&BnLY)SY9r)NVTMs);1<=oosS9g31l{4ztjD3#+2H7u_|66b|_*O z;Qk6nalpqdHOjx|K&vUS_6ITgGll;TdaN*ta=M_YtyC)I9Tmr~VaPrH2qb6sd~=AcIxV+%z{E&0@y=DPArw zdV7z(G1hBx7hd{>(cr43^WF%4Y@PXZ?wPpj{OQ#tvc$pABJbvPGvdR`cAtHn)cSEV zrpu}1tJwQ3y!mSmH*uz*x0o|CS<^w%&KJzsj~DU0cLQUxk5B!hWE>aBkjJle8z~;s z-!A=($+}Jq_BTK5^B!`R>!MulZN)F=iXXeUd0w5lUsE5VP*H*oCy(;?S$p*TVvTxwAeWFB$jHyb0593)$zqalVlDX=GcCN1gU0 zlgU)I$LcXZ8Oyc2TZYTPu@-;7<4YYB-``Qa;IDcvydIA$%kHhJKV^m*-zxcvU4viy&Kr5GVM{IT>WRywKQ9;>SEiQD*NqplK-KK4YR`p0@JW)n_{TU3bt0 zim%;(m1=#v2}zTps=?fU5w^(*y)xT%1vtQH&}50ZF!9YxW=&7*W($2kgKyz1mUgfs zfV<*XVVIFnohW=|j+@Kfo!#liQR^x>2yQdrG;2o8WZR+XzU_nG=Ed2rK?ntA;K5B{ z>M8+*A4!Jm^Bg}aW?R?6;@QG@uQ8&oJ{hFixcfEnJ4QH?A4>P=q29oDGW;L;= z9-a0;g%c`C+Ai!UmK$NC*4#;Jp<1=TioL=t^YM)<<%u#hnnfSS`nq63QKGO1L8RzX z@MFDqs1z ztYmxDl@LU)5acvHk)~Z`RW7=aJ_nGD!mOSYD>5Odjn@TK#LY{jf?+piB5AM-CAoT_ z?S-*q7}wyLJzK>N%eMPuFgN)Q_otKP;aqy=D5f!7<=n(lNkYRXVpkB{TAYLYg{|(jtRqYmg$xH zjmq?B(RE4 zQx^~Pt}gxC2~l=K$$-sYy_r$CO(d=+b3H1MB*y_5g6WLaWTXn+TKQ|hNY^>Mp6k*$ zwkovomhu776vQATqT4blf~g;TY(MWCrf^^yfWJvSAB$p5l;jm@o#=!lqw+Lqfq>X= z$6~kxfm7`3q4zUEB;u4qa#BdJxO!;xGm)wwuisj{0y2x{R(IGMrsIzDY9LW>m!Y`= z04sx3IjnYvL<4JqxQ8f7qYd0s2Ig%`ytYPEMKI)s(LD}D@EY>x`VFtqvnADNBdeao zC96X+MxnwKmjpg{U&gP3HE}1=s!lv&D{6(g_lzyF3A`7Jn*&d_kL<;dAFx!UZ>hB8 z5A*%LsAn;VLp>3${0>M?PSQ)9s3}|h2e?TG4_F{}{Cs>#3Q*t$(CUc}M)I}8cPF6% z=+h(Kh^8)}gj(0}#e7O^FQ6`~fd1#8#!}LMuo3A0bN`o}PYsm!Y}sdOz$+Tegc=qT z8x`PH$7lvnhJp{kHWb22l;@7B7|4yL4UOOVM0MP_>P%S1Lnid)+k9{+3D+JFa#Pyf zhVc#&df87APl4W9X)F3pGS>@etfl=_E5tBcVoOfrD4hmVeTY-cj((pkn%n@EgN{0f zwb_^Rk0I#iZuHK!l*lN`ceJn(sI{$Fq6nN& zE<-=0_2WN}m+*ivmIOxB@#~Q-cZ>l136w{#TIJe478`KE7@=a{>SzPHsKLzYAyBQO zAtuuF$-JSDy_S@6GW0MOE~R)b;+0f%_NMrW(+V#c_d&U8Z9+ec4=HmOHw?gdjF(Lu zzra83M_BoO-1b3;9`%&DHfuUY)6YDV21P$C!Rc?mv&{lx#f8oc6?0?x zK08{WP65?#>(vPfA-c=MCY|%*1_<3D4NX zeVTi-JGl2uP_2@0F{G({pxQOXt_d{g_CV6b?jNpfUG9;8yle-^4KHRvZs-_2siata zt+d_T@U$&t*xaD22(fH(W1r$Mo?3dc%Tncm=C6{V9y{v&VT#^1L04vDrLM9qBoZ4@ z6DBN#m57hX7$C(=#$Y5$bJmwA$T8jKD8+6A!-IJwA{WOfs%s}yxUw^?MRZjF$n_KN z6`_bGXcmE#5e4Ym)aQJ)xg3Pg0@k`iGuHe?f(5LtuzSq=nS^5z>vqU0EuZ&75V%Z{ zYyhRLN^)$c6Ds{f7*FBpE;n5iglx5PkHfWrj3`x^j^t z7ntuV`g!9Xg#^3!x)l*}IW=(Tz3>Y5l4uGaB&lz{GDjm2D5S$CExLT`I1#n^lBH7Y zDgpMag@`iETKAI=p<5E#LTkwzVR@=yY|uBVI1HG|8h+d;G-qfuj}-ZR6fN>EfCCW z9~wRQoAPEa#aO?3h?x{YvV*d+NtPkf&4V0k4|L=uj!U{L+oLa(z#&iuhJr3-PjO3R z5s?=nn_5^*^Rawr>>Nr@K(jwkB#JK-=+HqwfdO<+P5byeim)wvqGlP-P|~Nse8=XF zz`?RYB|D6SwS}C+YQv+;}k6$-%D(@+t14BL@vM z2q%q?f6D-A5s$_WY3{^G0F131bbh|g!}#BKw=HQ7mx;Dzg4Z*bTLQSfo{ed{4}NZW zfrRm^Ca$rlE{Ue~uYv>R9{3smwATcdM_6+yWIO z*ZRH~uXE@#p$XTbCt5j7j2=86e{9>HIB6xDzV+vAo&B?KUiMP|ttOElepnl%|DPqL b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN literal 0 HcmV?d00001 diff --git a/scripts/config-overrides.js b/scripts/config-overrides.js new file mode 100644 index 0000000..d9a9211 --- /dev/null +++ b/scripts/config-overrides.js @@ -0,0 +1,117 @@ +const CopyWebpackPlugin = require("copy-webpack-plugin"); +const LiveReloadPlugin = require("webpack-livereload-plugin"); +const path = require("path"); + +module.exports = { + webpack: function (config, env) { + if (!config.plugins) config.plugins = []; + + /** + * Use index.php instead of index.html + */ + const htmlPluginIndex = config.plugins.findIndex( + obj => + obj.options !== "undefined" && + obj.options.filename !== "undefined" && + obj.options.template !== "undefined" + ); + if (htmlPluginIndex > -1) { + config.plugins[htmlPluginIndex].options = Object.assign( + config.plugins[htmlPluginIndex].options, + { + template: config.plugins[htmlPluginIndex].options.template.replace( + ".html", + ".php" + ), + filename: "index.php", + } + ); + } + + /** + * Excluding PHP from asset bundling. + * + * File-loader automatically handles every file that is not + * explicitly tested for in webpack.config.js + */ + const moduleRules = config.module.rules[config.module.rules.length - 1].oneOf; + const fileLoaderRuleIndex = moduleRules.findIndex( + obj => typeof obj.loader === "string" && obj.loader.indexOf("file-loader") > -1 + ); + moduleRules[fileLoaderRuleIndex].exclude.push(/\.php$/); + config.module.rules[config.module.rules.length - 1].oneOf = moduleRules; + + /* + var util = require("util"); + var fs = require("fs"); + fs.writeFileSync("./data.json", util.inspect(moduleRules), "utf-8"); + */ + + /** + * Custom Webpack plugins + */ + config.plugins.push( + // Copies all files found in /public to the build directory + // `index.php` won't be copied, as it is still being modified by webpack by other plugins + // This way, Webpack will also keep an eye on these files in watch mode. + new CopyWebpackPlugin([ + { + from: "public/", + ignore: ["index.php", "*.old.*"], + force: true, + }, + { + from: "submodules/getID3/getid3/*gif*", + to: "protected/getID3", + flatten: true, + }, + { + from: "submodules/getID3/getid3/*jpg*", + to: "protected/getID3", + flatten: true, + }, + { + from: "submodules/getID3/getid3/*png*", + to: "protected/getID3", + flatten: true, + }, + { + from: "submodules/getID3/getid3/*svg*", + to: "protected/getID3", + flatten: true, + }, + { + from: "submodules/getID3/getid3/*getid3*", + to: "protected/getID3", + flatten: true, + }, + { + from: "submodules/getID3/getid3/*audio-video.mpeg*", + to: "protected/getID3", + flatten: true, + }, + { + from: "submodules/getID3/getid3/*tag*", + to: "protected/getID3", + flatten: true, + }, + ]) + ); + + if (process.env.NODE_ENV === "development") { + config.plugins.push( + new LiveReloadPlugin({ + appendScriptTag: true, + quiet: true, + }) + ); + } + + return config; + }, + paths: function (origPaths, env) { + return Object.assign(origPaths, { + appHtml: path.resolve(__dirname, "../public/index.php"), + }); + }, +}; diff --git a/scripts/watch.js b/scripts/watch.js new file mode 100644 index 0000000..d27b152 --- /dev/null +++ b/scripts/watch.js @@ -0,0 +1,166 @@ +/** + * This script uses CRA's Webpack config + * and spawns a watching Webpack-CLI. + * + * Webpack-Dev-Server is useless for us, + * as it cannot read any + */ +process.env.NODE_ENV = "development"; +process.env.NODE_ENV = "development"; + +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on("unhandledRejection", err => { + throw err; +}); + +const fs = require("fs-extra"); +const paths = require("react-scripts/config/paths"); +const path = require("path"); +const webpack = require("webpack"); +const webpackconfig = require("react-scripts/config/webpack.config.js"); +const config = webpackconfig("development"); +const rewire = require("rewire"); +const chalk = require("chalk"); + +/** + * Rewire compilation success screen + */ +const reactDevUtils = rewire("react-dev-utils/WebpackDevServerUtils"); +reactDevUtils.__set__("printInstructions", (appName, urls, useYarn) => { + console.log(); + console.log( + chalk.cyan("ℹ️ The development bundle was output to " + chalk.bold("dist")) + ); + console.log( + ` To see ${chalk.bold( + appName + )} it in action, you need to route a php + mysql web-server to the folder's location.` + ); + console.log(); + console.log(` You can read more about this setup in the ${chalk.bold("README")}:`); + console.log( + ` ${chalk.underline("https://github.com/pogodaanton/Shadis#available-scripts")}` + ); + + console.log(); + console.log(); + console.log(chalk.cyan("📡 Webpack is in watch mode")); + console.log( + ` As soon as it detects a change in the file system, + it will recompile and reload any pages using this dev build.` + ); + + console.log(); + console.log(); + console.log(chalk.cyan("📊 This development build is not optimized")); + console.log( + ` To create a production build, use ` + + `${chalk.yellow(`${useYarn ? "yarn" : "npm run"} build`)}.` + ); + console.log(); + console.log(chalk.dim("Last successful build: " + new Date().toLocaleTimeString())); + console.log(); +}); + +/** + * Integrate react-app-rewire and customize-cra changes + */ +const overrides = require("react-app-rewired/config-overrides"); +overrides.webpack(config, process.env.NODE_ENV); + +/** + * Remove react-dev-utils/webpackHotDevClient.js + */ +config.entry = config.entry.filter(fileName => !fileName.match(/webpackHotDevClient/)); +config.plugins = config.plugins.filter( + plugin => !(plugin instanceof webpack.HotModuleReplacementPlugin) +); + +/** + * Remove optimizations to fasten compilation + */ +config.mode = "development"; +config.devtool = "eval-cheap-module-source-map"; +delete config.optimization; + +// fix publicPath and output path +// config.output.publicPath = pkg.homepage +// config.output.path = paths.appBuild // else it will put the outputs in the dist folder + +/** + * Empty `dist` directory + * and protect the `uploads` folder + */ +const distPath = path.resolve(__dirname, "../dist"); +let distItems; +try { + distItems = fs.readdirSync(distPath); +} catch { + return fs.mkdirsSync(distPath); +} + +distItems.forEach(item => { + if (item === "uploads") return; + item = path.join(distPath, item); + fs.removeSync(item); +}); + +/** + * Use react-script's own compiler instance + * and assign a watch listener to it. + */ + +// Dependency arguments +const appName = require(paths.appPackageJson).name; +const useYarn = fs.existsSync(paths.yarnLockFile); +const useTypeScript = fs.existsSync(paths.appTsConfig); +const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === "true"; + +// Mock socket: We don't have a dev server running +const devSocket = { + warnings: warnings => null, + errors: errors => null, +}; + +// Mock URLs: We won't run a dev server, this is unnecessary. +const urls = { + lanUrlForConfig: "", + lanUrlForTerminal: "", + localUrlForTerminal: "", + localUrlForBrowser: "", +}; + +// Create a webpack compiler that is configured with custom messages. +const compiler = reactDevUtils.createCompiler({ + appName, + config, + devSocket, + urls, + useYarn, + useTypeScript, + tscCompileOnError, + webpack, +}); + +// Watch for changes and copy public folder if compilation is finished +compiler.watch({}, (err, stats) => { + if (err) console.error(err); + + /* + console.error( + stats.toString({ + chunks: false, + colors: true, + }) + ); + */ +}); + +function copyPublicFolder() { + fs.copySync(paths.appPublic, paths.appBuild, { + dereference: true, + filter: file => file !== paths.appHtml, + }); +} diff --git a/src/App/AnimatedRoutes.tsx b/src/App/AnimatedRoutes.tsx new file mode 100755 index 0000000..98eb7e1 --- /dev/null +++ b/src/App/AnimatedRoutes.tsx @@ -0,0 +1,127 @@ +import loadable, { LoadableComponent } from "@loadable/component"; +import { FileViewProps } from "../FileView/FileView.props"; +import { FullscreenLoader } from "../Loader"; +import { RouteChildrenProps } from "react-router-dom"; +import React, { useRef, useState, useEffect } from "react"; +import axios from "../_interceptedAxios"; +import { AnimatePresence, AnimateSharedLayout } from "framer-motion"; +import { useToasts, isLoggedIn } from "../_DesignSystem"; +import { useTranslation } from "react-i18next"; +import { History } from "history"; + +/** + * Lazy-loading components + */ +const FileView: LoadableComponent = FullscreenLoader( + import(/* webpackChunkName: "FileView" */ "../FileView/FileView") +); +const Login: LoadableComponent<{}> = loadable(() => + import(/* webpackChunkName: "Login" */ "../Login/Login") +); +const NotFound: LoadableComponent<{}> = loadable(() => + import(/* webpackChunkName: "NotFound" */ "../NotFound/NotFound") +); + +// Due to the HOC It is somewhat hard to assign the proper props to it +const Dashboard: LoadableComponent = loadable(() => + import(/* webpackChunkName: "Dashboard" */ "../Dashboard/Dashboard") +); + +/** + * Prefetcher for + * + * Since AnimateSharedLayout expects direct children, + * we need to fetch all necessary data + * before the actual component is mounted. + */ +const useFilePrefetcher = (id: string, history: History<{}>) => { + const [fileData, setFileData] = useState(null); + const { addToast } = useToasts(); + const { t } = useTranslation("common"); + + useEffect(() => { + const fetchFileData = async (id: string) => { + try { + const res = await axios.get(window.location.origin + "/api/get.php", { + params: { id }, + }); + + if (!res.data) { + setFileData({} as any); + } else { + setFileData(res.data); + } + } catch (err) { + addToast(t("error.requestFile", { id }), { appearance: "error" }); + console.log(t("error.requestFile", { id }), "\n", err.message); + history.replace("/"); + } + }; + + if (window.fileData) { + setFileData(window.fileData); + window.fileData = null; + return; + } + + if (id) fetchFileData(id); + else setFileData(null); + }, [addToast, history, id, t]); + + return fileData; +}; + +/** + * Sets up connected animations between dashboard an view components + * if the user is logged in and prefetches "fileData" on each "/:id" route + */ +const AnimatedRoutes: React.FC> = ({ + match, + history, +}) => { + const ViewRef = useRef>(null); + const [viewLoaded, setViewLoaded] = useState(false); + const fileData = useFilePrefetcher(match.params.id, history); + + /** + * AnimateSharedLayout doesn't like stand-in components, + * so we need to load the component ourselves. + * + * ViewRef is the loaded component without a loadable wrapper. + * viewLoaded is only used to rerender the component. + * + * This is only needed in loggedin state, as we don't care about + * connected animations if there is nothing to connect them to. + */ + useEffect(() => { + if (!ViewRef.current) { + FileView.load().then((exported: any) => { + ViewRef.current = exported.default; + setViewLoaded(true); + }); + } + }, []); + + const is404 = + !!match.params.id && fileData !== null && typeof fileData.id === "undefined"; + const isValidFile = + !!match.params.id && + viewLoaded && + ViewRef.current && + fileData && + typeof fileData.id !== "undefined"; + const isDashboardVisible = isLoggedIn && !is404; + + return ( + + {isDashboardVisible && } + + {isValidFile && } + {is404 && } + {!match.params.id && !isLoggedIn && } + + + ); +}; + +export default AnimatedRoutes; diff --git a/src/App/App.props.ts b/src/App/App.props.ts new file mode 100755 index 0000000..f717301 --- /dev/null +++ b/src/App/App.props.ts @@ -0,0 +1,17 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface AppContainerClassNameContract { + /** + * Root of the component + */ + container?: string; +} + +/** + * Props for the component + */ +export interface AppContainerProps + extends ManagedClasses {} diff --git a/src/App/App.tsx b/src/App/App.tsx new file mode 100755 index 0000000..5619c9f --- /dev/null +++ b/src/App/App.tsx @@ -0,0 +1,31 @@ +import React, { Suspense } from "react"; +import { Background } from "@microsoft/fast-components-react-msft"; +import { DesignSystem, neutralLayerL1 } from "@microsoft/fast-components-styles-msft"; +import { BrowserRouter as Router, Route } from "react-router-dom"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { AppContainerClassNameContract, AppContainerProps } from "./App.props"; +import AnimatedRoutes from "./AnimatedRoutes"; + +const styles: ComponentStyles = { + container: { + display: "flex", + width: "100%", + minHeight: "100%", + flexDirection: "column", + "& > div": { + flexGrow: "1", + }, + }, +}; + +const AppContainer: React.ComponentType = ({ managedClasses }) => ( + + + + + + + +); + +export default manageJss(styles)(AppContainer); diff --git a/src/Dashboard/Dashboard.props.ts b/src/Dashboard/Dashboard.props.ts new file mode 100755 index 0000000..8be9783 --- /dev/null +++ b/src/Dashboard/Dashboard.props.ts @@ -0,0 +1,15 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface DashboardClassNameContract { + dashboard__frozen?: string; +} + +/** + * Props for the component + */ +export interface DashboardProps extends ManagedClasses { + frozen?: boolean; +} diff --git a/src/Dashboard/Dashboard.tsx b/src/Dashboard/Dashboard.tsx new file mode 100755 index 0000000..2b5a240 --- /dev/null +++ b/src/Dashboard/Dashboard.tsx @@ -0,0 +1,95 @@ +import React, { useState, useEffect } from "react"; +import { DesignSystem } from "@microsoft/fast-components-styles-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { DashboardClassNameContract, DashboardProps } from "./Dashboard.props"; +import { useToasts, Header } from "../_DesignSystem"; +import DashboardEmpty from "./views/DashboardEmpty"; +import { withDropzone } from "../FullscreenDropzone/FullscreenDropzone"; +import axios from "../_interceptedAxios"; +import { useTranslation } from "react-i18next"; +import { ListDataItem, DashboardListProps } from "../DashboardList/DashboardList.props"; +import { FullscreenLoader } from "../Loader"; +import { LoadableComponent } from "@loadable/component"; + +const DashboardList: LoadableComponent = FullscreenLoader( + import("../DashboardList/DashboardList") +); + +const styles: ComponentStyles = { + dashboard__frozen: { + pointerEvents: "none", + overflow: "hidden", + }, +}; + +const Dashboard: React.FC = props => { + const [listData, setListData] = useState(null); + const [isFrozen, setFrozenState] = useState(false); + const { addToast } = useToasts(); + const { t } = useTranslation("dashboard"); + + /** + * If the view component is loaded, this component stays in the background. + * We halt all computations in this component as long as that is the case. + */ + useEffect(() => { + if (props.frozen !== isFrozen) setFrozenState(props.frozen); + }, [isFrozen, props.frozen]); + + useEffect(() => { + const updateFileList = async () => { + try { + const res = await axios.get(window.location.origin + "/api/getAll.php"); + setListData(res.data); + } catch (err) { + addToast(t(err.i18n, err.message), { + appearance: "error", + title: t("error.listGeneric") + ":", + }); + console.log(`${t("error.listGeneric")}:\n`, `(${err.code}) - ${err.message}`); + } + }; + + updateFileList(); + }, [addToast, t]); + + const onDeleteSelected = async (selection: string[]) => { + try { + await axios.post(window.location.origin + "/api/edit.php", { + selection, + action: "delete", + }); + + addToast("", { + appearance: "success", + title: t("selectedDeleted", { count: selection.length }), + }); + setListData( + listData.filter(obj => selection.findIndex(id => id === obj.id) === -1) + ); + } catch (err) { + addToast(t(err.i18n, err.message), { + appearance: "error", + title: t("error.requestGeneric") + ":", + }); + console.log(`${t("error.requestGeneric")}:\n`, `(${err.code}) - ${err.message}`); + } + }; + + return ( +
+
+ {listData === null ? null : listData.length === 0 ? ( + + ) : ( + + )} +
+ ); +}; + +export default withDropzone(manageJss(styles)(Dashboard)); diff --git a/src/Dashboard/views/DashboardEmpty.props.ts b/src/Dashboard/views/DashboardEmpty.props.ts new file mode 100755 index 0000000..6344487 --- /dev/null +++ b/src/Dashboard/views/DashboardEmpty.props.ts @@ -0,0 +1,15 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface DashboardEmptyClassNameContract { + dashboardEmpty?: string; + dashboardEmptyIcon?: string; +} + +/** + * Props for the component + */ +export interface DashboardEmptyProps + extends ManagedClasses {} diff --git a/src/Dashboard/views/DashboardEmpty.tsx b/src/Dashboard/views/DashboardEmpty.tsx new file mode 100755 index 0000000..065329e --- /dev/null +++ b/src/Dashboard/views/DashboardEmpty.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { Heading, HeadingTag } from "@microsoft/fast-components-react-msft"; +import { + DesignSystem, + neutralForegroundRest, +} from "@microsoft/fast-components-styles-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { + DashboardEmptyClassNameContract, + DashboardEmptyProps, +} from "./DashboardEmpty.props"; +import { FaFolderOpen } from "react-icons/fa"; +import { useTranslation } from "react-i18next"; + +const styles: ComponentStyles = { + dashboardEmpty: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + opacity: "0.3", + }, + dashboardEmptyIcon: { + fontSize: "4em", + color: (designSystem: DesignSystem): string => neutralForegroundRest(designSystem), + }, +}; + +const DashboardEmpty: React.FC = React.memo(props => { + const { t } = useTranslation("dashboard"); + + return ( +
+ + + {t("emptyTitle")} + + + {t("emptyDescription")} + +
+ ); +}); + +export default manageJss(styles)(DashboardEmpty); diff --git a/src/DashboardList/DashboardList.props.ts b/src/DashboardList/DashboardList.props.ts new file mode 100755 index 0000000..0bf0919 --- /dev/null +++ b/src/DashboardList/DashboardList.props.ts @@ -0,0 +1,28 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface DashboardListClassNameContract { + dashboardList: string; +} + +/** + * Object contents in an item of the listData array + */ +export interface ListDataItem { + id: string; + thumb_height: number; + timestamp: number; + title: string; +} + +/** + * Props for the component + */ +export interface DashboardListProps + extends ManagedClasses { + listData: ListDataItem[]; + onDeleteSelected: (selectedItems: string[]) => void; + frozen?: boolean; +} diff --git a/src/DashboardList/DashboardList.tsx b/src/DashboardList/DashboardList.tsx new file mode 100755 index 0000000..d03d600 --- /dev/null +++ b/src/DashboardList/DashboardList.tsx @@ -0,0 +1,183 @@ +import React, { useRef, useState, useLayoutEffect } from "react"; +import { DesignSystem } from "@microsoft/fast-components-styles-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { + DashboardListClassNameContract, + DashboardListProps, +} from "./DashboardList.props"; +import { + CellMeasurerCache, + createMasonryCellPositioner, + Masonry, + WindowScroller, + AutoSizer, +} from "react-virtualized"; +import { debounce } from "lodash-es"; +import DashboardListCell from "./views/DashboardListCell"; +import DashboardListToolbar from "./views/DashboardListToolbar"; + +const styles: ComponentStyles = { + dashboardList: { + "& .ReactVirtualized__Masonry, & .ReactVirtualized__Masonry__innerScrollContainer": { + outline: "none", + }, + }, +}; + +// Defaults for masonry cells +const columnWidth = 200; +const spacer = 10; +let currentColumnCount = 0; + +// Default sizes help Masonry decide how many images to batch-measure +const cache = new CellMeasurerCache({ + defaultHeight: 250, + minHeight: 45, + defaultWidth: 200, + fixedWidth: true, +}); + +// Our masonry layout will use 3 columns with a 10px gutter between +const cellPositionerParams = { + cellMeasurerCache: cache, + columnCount: 3, + columnWidth: 200, + spacer: 10, +}; +const cellPositioner = createMasonryCellPositioner(cellPositionerParams); + +/** + * The initial values for `masonryBounds` + */ +const initialMasonryBounds = { + width: 0, + left: 0, + pageWidth: 0, +}; + +/** + * Calculates the amount of columns in a Masonry based on the given available width. + * After a specific width, the Masonry won't become wider, as that would reduce usability. + * + * @param availableWidth Width to fit the Masonry columns in. + */ +const calculateMasonryColumnCount = (availableWidth: number): number => { + if (availableWidth > 1300) availableWidth = 1300; + return Math.floor((availableWidth + spacer) / (columnWidth + spacer)); +}; + +const DashboardList: React.FC = React.memo( + ({ listData, managedClasses, onDeleteSelected, frozen }) => { + const masonryRef = useRef(null); + const onResizeRef = useRef(null); + const [selectedItems, setSelectedItems] = useState([]); + const [masonryBounds, setMasonryBounds] = useState(initialMasonryBounds); + + /** + * Reset static caches and size data on unmount + */ + useLayoutEffect( + () => () => { + cache.clearAll(); + cellPositioner.reset(cellPositionerParams); + currentColumnCount = 0; + }, + [] + ); + + // Positions the masonry to the centre of the page. + const onResize = ({ width: pageWidth }: { width: number }, forceUpdate?: boolean) => { + const columnCount = calculateMasonryColumnCount(pageWidth); + + if (columnCount !== currentColumnCount || forceUpdate) { + cellPositioner.reset({ + columnCount: columnCount, + columnWidth, + spacer, + }); + + if (masonryRef.current) masonryRef.current.recomputeCellPositions(); + currentColumnCount = columnCount; + } + + const width = columnCount * (columnWidth + spacer) - spacer; + const left = Math.floor((pageWidth - width) / 2); + setMasonryBounds({ width, left, pageWidth }); + }; + + // A debounced function must not be redefined on each rerender + const debouncedOnResize = () => { + if (onResizeRef.current === null) { + onResizeRef.current = debounce(onResize, 80); + return onResize; + } + + // We don't want to update the component unnecessarily if there is no need to + if (!frozen) return onResizeRef.current; + }; + + // Selection occurs in a + const selectItem = (id: string) => (selected: boolean) => { + if (!selected) setSelectedItems(selectedItems.filter(item => item !== id)); + else setSelectedItems([...selectedItems, id]); + }; + + // We need to clear the selection list beforehand + const onDelete = () => { + onDeleteSelected(selectedItems); + setSelectedItems([]); + }; + + // If one or more is selected, we enter select mode + const inSelectMode = selectedItems.length > 0; + + return ( +
+ + {listData !== null && listData.length > 0 && ( + + {({ height, scrollTop }) => ( + + {() => ( + { + const data = listData[props.index]; + if (typeof data === "undefined") return null; + return ( + i === data.id) > -1} + selectMode={inSelectMode} + /> + ); + }} + autoHeight={true} + height={height} + scrollTop={scrollTop} + width={masonryBounds.width} + overscanByPixels={100} + style={{ left: masonryBounds.left }} + /> + )} + + )} + + )} +
+ ); + } +); + +export default manageJss(styles)(DashboardList); diff --git a/src/DashboardList/views/DashboardListCell.props.tsx b/src/DashboardList/views/DashboardListCell.props.tsx new file mode 100755 index 0000000..582e71f --- /dev/null +++ b/src/DashboardList/views/DashboardListCell.props.tsx @@ -0,0 +1,28 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; +import { MasonryCellProps, CellMeasurerCache } from "react-virtualized"; +import { ListDataItem } from "../DashboardList.props"; + +/** + * Class name contract for the component + */ +export interface DashboardListCellClassNameContract { + dashboardListCell: string; + dashboardListCell__checked?: string; + dashboardListCell_image: string; + dashboardListCell_metadata: string; + dashboardListCell_checkbox: string; + dashboardListCell_overlay?: string; +} + +/** + * Props for the component + */ +export interface DashboardListCellProps + extends ManagedClasses, + MasonryCellProps { + cache: CellMeasurerCache; + data: ListDataItem; + selected: boolean; + selectMode: boolean; + onSelect: (selected: boolean) => void; +} diff --git a/src/DashboardList/views/DashboardListCell.tsx b/src/DashboardList/views/DashboardListCell.tsx new file mode 100755 index 0000000..1eb3275 --- /dev/null +++ b/src/DashboardList/views/DashboardListCell.tsx @@ -0,0 +1,232 @@ +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { + DashboardListCellProps, + DashboardListCellClassNameContract, +} from "./DashboardListCell.props"; +import React, { useRef } from "react"; +import { + neutralLayerL2, + DesignSystem, + backgroundColor, + accentFillSelected, + neutralForegroundRest, + applyElevation, + ElevationMultiplier, + applyElevatedCornerRadius, +} from "@microsoft/fast-components-styles-msft"; +import { useTranslation } from "react-i18next"; +import { + Label, + Checkbox, + CheckboxClassNameContract, +} from "@microsoft/fast-components-react-msft"; +import { parseColorHexRGBA } from "@microsoft/fast-colors"; +import { classNames } from "@microsoft/fast-web-utilities"; +import { motion } from "framer-motion"; +import { Link } from "react-router-dom"; + +const styles: ComponentStyles = { + dashboardListCell: { + background: neutralLayerL2, + overflow: "hidden", + ...applyElevatedCornerRadius(), + ...applyElevation(ElevationMultiplier.e4), + cursor: "pointer", + "&:hover": { + "& $dashboardListCell_metadata": { + transform: "translateY(0%)", + transition: "transform .2s .1s cubic-bezier(0.1, 0.9, 0.2, 1)", + }, + "& $dashboardListCell_checkbox": { + opacity: "1", + }, + }, + "&$dashboardListCell__checked $dashboardListCell_metadata": { + transform: "translateY(0%)", + }, + "& > a": { + display: "block", + }, + }, + dashboardListCell_image: { + width: "200px", + objectFit: "contain", + background: neutralLayerL2, + userDrag: "none", + userSelect: "none", + opacity: "0", + transition: "opacity .1s", + }, + dashboardListCell_metadata: { + position: "absolute", + bottom: "0px", + left: "0px", + width: "100%", + padding: "10px", + boxSizing: "border-box", + textAlign: "center", + background: des => parseColorHexRGBA(backgroundColor(des) + "cc").toStringWebRGBA(), + transform: "translateY(100%)", + transition: "transform .2s cubic-bezier(0.9, 0.1, 1, 0.2)", + outline: "none", + cursor: "default", + "& > label": { + width: "100%", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + outline: "none", + }, + }, + dashboardListCell_checkbox: { + position: "absolute", + top: "10px", + left: "10px", + zIndex: "2", + opacity: "0", + transition: "opacity 0.08s .1s", + ".selecting &": { + opacity: "1", + }, + }, + dashboardListCell_overlay: { + display: "none", + position: "absolute", + top: "0", + left: "0", + width: "100%", + height: "100%", + boxSizing: "border-box", + borderWidth: "2px", + borderStyle: "solid", + borderColor: neutralForegroundRest, + zIndex: "1", + outline: "none", + ...applyElevatedCornerRadius(), + background: (des: DesignSystem) => + parseColorHexRGBA(accentFillSelected(des) + "aa").toStringWebRGBA(), + }, + dashboardListCell__checked: { + "& $dashboardListCell_overlay": { + display: "block", + }, + }, +}; + +const checkboxStyle: ComponentStyles = { + checkbox_input: { + borderRadius: "50%", + }, + checkbox_stateIndicator: {}, + checkbox__checked: { + "& $checkbox_stateIndicator": { + "&::before": { + backgroundSize: "17px", + backgroundPosition: "1px 2px", + }, + }, + }, +}; + +const onImageLoaded: React.ReactEventHandler = ({ currentTarget }) => { + currentTarget.style.opacity = "1"; + currentTarget.style.pointerEvents = "none"; +}; + +const onImageError: React.ReactEventHandler = ({ currentTarget }) => { + currentTarget.style.display = "none"; + currentTarget.style.pointerEvents = "none"; +}; + +/** + * Renders a cell and sets its height in the CellMeasurementCache + */ +const CellRenderer: React.FC = props => { + let { id, title, thumb_height } = props.data; + const { t } = useTranslation("dashboard"); + const cellRef = useRef(null); + + // i18n-ize the title; `untitled` will call a translation string + title = title === "" || title === "untitled" || !title ? t("untitled") : title; + + // We already know the thumbnail size, so we take over the work of + if (!props.cache.has(props.index, 0)) { + props.cache.set(props.index, 0, 200, thumb_height); + if ( + props.parent && + typeof props.parent.invalidateCellSizeAfterRender === "function" + ) { + props.parent.invalidateCellSizeAfterRender({ + columnIndex: 0, + rowIndex: props.index, + }); + } + } + + const onCheckmarkChange = ( + e: React.ChangeEvent & React.MouseEvent + ) => { + if (typeof e.currentTarget.checked !== "undefined" || props.selectMode) { + props.onSelect(!props.selected); + } + }; + + const onAnimationStart = () => { + if (cellRef.current) cellRef.current.style.zIndex = "400"; + }; + + const onAnimationEnd = () => { + if (cellRef.current) cellRef.current.style.zIndex = "auto"; + }; + + const shouldExecuteclick: React.MouseEventHandler = e => { + props.selectMode && e.preventDefault(); + }; + + return ( + + + {title} + + +
+
+ +
+ + ); +}; + +export default manageJss(styles)(CellRenderer); diff --git a/src/DashboardList/views/DashboardListToolbar.props.ts b/src/DashboardList/views/DashboardListToolbar.props.ts new file mode 100755 index 0000000..3796830 --- /dev/null +++ b/src/DashboardList/views/DashboardListToolbar.props.ts @@ -0,0 +1,19 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface DashboardListToolbarClassNameContract { + dashboardListToolbar: string; + dashboardListToolbar__visible?: string; +} + +/** + * Props for the component + */ +export interface DashboardListToolbarProps + extends ManagedClasses { + visible?: boolean; + selectedAmount?: number; + onDelete: () => void; +} diff --git a/src/DashboardList/views/DashboardListToolbar.tsx b/src/DashboardList/views/DashboardListToolbar.tsx new file mode 100755 index 0000000..b136efe --- /dev/null +++ b/src/DashboardList/views/DashboardListToolbar.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { + DashboardListToolbarProps, + DashboardListToolbarClassNameContract, +} from "./DashboardListToolbar.props"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { DesignSystem } from "@microsoft/fast-components-styles-msft"; +import { Button, ButtonAppearance } from "../../_DesignSystem"; +import { useTranslation } from "react-i18next"; +import { FaTrash } from "react-icons/fa"; +import { classNames } from "@microsoft/fast-web-utilities"; + +const styles: ComponentStyles = { + dashboardListToolbar: { + position: "fixed", + top: "0", + left: "50%", + zIndex: "3", + transform: "translateX(-50%) translateY(-100%)", + transition: "transform .2s cubic-bezier(0.9, 0.1, 1, 0.2)", + }, + dashboardListToolbar__visible: { + transform: "translateX(-50%) translateY(15px)", + transition: "transform .2s cubic-bezier(0.1, 0.9, 0.2, 1)", + }, +}; + +const DahboardListToolbar: React.FC = props => { + const { t } = useTranslation("dashboard"); + + return ( +
+ +
+ ); +}; + +export default manageJss(styles)(DahboardListToolbar); diff --git a/src/FileView/FileView.props.ts b/src/FileView/FileView.props.ts new file mode 100755 index 0000000..05dc0b6 --- /dev/null +++ b/src/FileView/FileView.props.ts @@ -0,0 +1,17 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface FileViewClassNameContract { + fileView?: string; + fileViewContainer?: string; + fileViewBackground?: string; +} + +/** + * Props for the component + */ +export interface FileViewProps extends ManagedClasses { + fileData?: Window["fileData"]; +} diff --git a/src/FileView/FileView.tsx b/src/FileView/FileView.tsx new file mode 100755 index 0000000..cc5307a --- /dev/null +++ b/src/FileView/FileView.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useRef, useState } from "react"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { FileViewClassNameContract, FileViewProps } from "./FileView.props"; +import { + neutralForegroundRest, + DesignSystem, + neutralLayerL1, +} from "@microsoft/fast-components-styles-msft"; +import { motion } from "framer-motion"; +import { Header } from "../_DesignSystem"; +import { HeaderClassNameContract } from "../_DesignSystem/Header/Header.props"; +import ImageViewer from "./views/ImageViewer/ImageViewer"; +import { HeaderCenterContent } from "./views/HeaderContent/HeaderCenterContent"; +import { HeaderRightContent } from "./views/HeaderContent/HeaderRightContent"; +import { FVSidebarProps } from "./views/FVSidebar/FVSidebar.props"; +import { LoadableComponent } from "@loadable/component"; +import { FullscreenLoader } from "../Loader"; +import FVSidebarProvider from "./views/FVSidebar/FVSidebarContext"; +import FVSidebarToggleButton from "./views/FVSidebar/FVSidebarToggleButton"; + +const FVSidebar: LoadableComponent = FullscreenLoader( + import(/* webpackChunkName: "FVSidebar" */ "./views/FVSidebar/FVSidebar") +); + +const styles: ComponentStyles = { + fileView: { + display: "flex", + position: "fixed", + zIndex: "30", + top: "0", + left: "0", + minWidth: "100%", + minHeight: "100%", + }, + fileViewBackground: { + position: "absolute", + width: "100%", + height: "100%", + top: "0", + left: "0", + background: neutralLayerL1, + }, + fileViewContainer: { + position: "absolute", + top: "64px", + left: "0", + right: "0", + bottom: "0", + margin: "auto", + cursor: "zoom-in", + }, + fileViewIcon: { + fontSize: "4em", + color: (designSystem: DesignSystem): string => neutralForegroundRest(designSystem), + }, +}; + +const headerStyles: ComponentStyles = { + header: { + zIndex: "40", + }, + header_left: {}, +}; + +let largeImage: HTMLImageElement = null; + +const FileView: React.FC = ({ + managedClasses, + fileData, +}: FileViewProps) => { + const { id, extension, fromServer } = fileData; + + /** + * Helper functions as refs to prevent them from updating after + * each re-render + */ + const onImageLoaded = useRef<() => void>(null); + const onMagnify = useRef(() => {}); + + /** + * Used to decide whether to view the thumbnail or the original image + */ + const [largeImageLoaded, setLargeImageLoadedState] = useState(false); + + /** + * Image manipulation involves overflowing the body + * Moreover, uses a body scrollbar which we want to hide. + */ + useEffect(() => { + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = "visible"; + }; + }, []); + + /** + * Setting up imageLoad event handler for large image + */ + useEffect(() => { + onImageLoaded.current = () => setLargeImageLoadedState(true); + return () => { + if (largeImage) largeImage.removeEventListener("load", onImageLoaded.current); + }; + }, []); + + /** + * Load image in background before rendering + */ + const loadLargeImage = () => { + largeImage = new Image(); + largeImage.addEventListener("load", onImageLoaded.current); + largeImage.src = `${window.location.origin}/${id}.${extension}`; + }; + + // imageURL for + const imageURL = `${window.location.origin}/${id}.${ + largeImageLoaded || fromServer ? extension : "thumb.jpg" + }`; + + // Callback used after mounting ImageViewer + const setZoomRef = (ref: React.MouseEventHandler) => (onMagnify.current = ref); + + return ( + +
+ +
} + rightSideContent={} + /> + + + + {largeImageLoaded && } +
+
+ ); +}; + +export default manageJss(styles)(FileView); diff --git a/src/FileView/views/FVSidebar/FVSidebar.props.ts b/src/FileView/views/FVSidebar/FVSidebar.props.ts new file mode 100755 index 0000000..6336999 --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebar.props.ts @@ -0,0 +1,19 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface FVSidebarClassNameContract { + fv_sidebar: string; + fv_sidebar_button: string; + fv_sidebar_container: string; + fv_sidebar_content?: string; + fv_sidebar_footer?: string; +} + +/** + * Props for the component + */ +export interface FVSidebarProps extends ManagedClasses { + fileData: Window["fileData"]; +} diff --git a/src/FileView/views/FVSidebar/FVSidebar.tsx b/src/FileView/views/FVSidebar/FVSidebar.tsx new file mode 100755 index 0000000..37002e0 --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebar.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useContext } from "react"; +import { FVSidebarProps, FVSidebarClassNameContract } from "./FVSidebar.props"; +import { + DesignSystem, + neutralLayerL1, + neutralOutlineActive, + applyPillCornerRadius, +} from "@microsoft/fast-components-styles-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { motion, useTransform } from "framer-motion"; +import { tween } from "popmotion"; +import { cubicBezier } from "@popmotion/easing"; +import { Heading, HeadingSize, HeadingTag } from "@microsoft/fast-components-react-msft"; +import { SidebarData } from "./FVSidebarContext"; +import FVSidebarContent from "./FVSidebarContent"; +import FVSidebarFooter from "./FVSidebarFooter"; +import { useTranslation } from "react-i18next"; + +/** + * The position where on the x-axis the button is placed by default + */ +const defaultButtonPos = 17; + +/** + * The width of the sidebar by default + */ +const defaultSidebarWidth = 400; + +const styles: ComponentStyles = { + fv_sidebar_button: { + zIndex: "63", + position: "absolute", + right: defaultButtonPos + "px", + top: "14px", + ...applyPillCornerRadius(), + }, + fv_sidebar: { + display: "flex", + flexDirection: "column", + width: defaultSidebarWidth + "px", + height: "100%", + background: neutralLayerL1, + borderInlineStart: "1px solid", + borderInlineStartColor: neutralOutlineActive, + zIndex: "60", + position: "absolute", + top: "0", + right: "0", + "& > h1": { + padding: "19px 25px 17px 75px", + }, + }, + fv_sidebar_container: { + overflow: "auto", + display: "flex", + flexDirection: "column", + flexGrow: "1", + }, +}; + +const FVSidebar: React.ComponentType = ({ managedClasses, fileData }) => { + const { sidebarWidth, sidebarPos, isSidebarVisible } = useContext(SidebarData); + const { t } = useTranslation("fileview"); + + // const [isPresent, safeToRemove] = usePresence(); + + /** + * The transitionX position of the sidebar. + * + * We need to invert the values, as this value + * is relative to the initial position of the + * sidebar, which is inside the viewport. + */ + const sidebarContainerX = useTransform( + sidebarPos, + pos => -1 * (pos - sidebarWidth.get()) + ); + + /** + * Custom tween animator for opening and closing + */ + useEffect(() => { + sidebarPos.start(complete => { + const anim = tween({ + from: sidebarPos.get(), + to: isSidebarVisible ? defaultSidebarWidth : 0, + duration: isSidebarVisible ? 400 : 300, + ease: isSidebarVisible + ? cubicBezier(0.2, 0.66, 0, 1) + : cubicBezier(0.0, 0.0, 0.85, 0.05), + }).start({ + complete, + update: (val: number) => sidebarPos.set(val), + }); + return anim.stop; + }); + }, [sidebarPos, isSidebarVisible]); + + /** + * Close sidebar while unmounting component. + * We set a timeout for the image repositoning animation to kick in. + * + * TODO: Find a way to close sidebar BEFORE unmounting + * ! Maybe call an upper safeToRemove prop? + */ + /* + const allowUnmount = useCallback( + (pos: number) => { + if (!isPresent && pos === 0) { + setTimeout(safeToRemove, 200); + } + }, + [isPresent, safeToRemove] + ); + + /** + * Listen to isPresent for starting closing animation. + * + useEffect(() => { + if (!safeToRemove) return; + if (!isPresent) { + if (isSidebarVisible) setVisibility(false); + else if (!sidebarPos.isAnimating()) { + safeToRemove(); + return; + } + return sidebarPos.onChange(allowUnmount); + } + }, [allowUnmount, isPresent, safeToRemove, sidebarPos, isSidebarVisible]);*/ + + return ( + <> + + + {t("inspector")} + +
+ + +
+
+ + ); +}; + +export default manageJss(styles)(FVSidebar); diff --git a/src/FileView/views/FVSidebar/FVSidebarContent.props.ts b/src/FileView/views/FVSidebar/FVSidebarContent.props.ts new file mode 100755 index 0000000..c29b144 --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebarContent.props.ts @@ -0,0 +1,17 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface FVSidebarContentClassNameContract { + fv_sidebarContent: string; + fv_sidebarContent_list: string; +} + +/** + * Props for the component + */ +export interface FVSidebarContentProps + extends ManagedClasses { + fileData: Window["fileData"]; +} diff --git a/src/FileView/views/FVSidebar/FVSidebarContent.tsx b/src/FileView/views/FVSidebar/FVSidebarContent.tsx new file mode 100755 index 0000000..2091fd3 --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebarContent.tsx @@ -0,0 +1,67 @@ +import React, { memo } from "react"; +import { + FVSidebarContentProps, + FVSidebarContentClassNameContract, +} from "./FVSidebarContent.props"; +import { + DesignSystem, + neutralForegroundRest, + neutralForegroundHover, +} from "@microsoft/fast-components-styles-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { useTranslation } from "react-i18next"; +import { isLoggedIn } from "../../../_DesignSystem"; +import FVSidebarDescEditor from "./FVSidebarDescEditor"; + +const styles: ComponentStyles = { + fv_sidebarContent: { + flexGrow: "1", + padding: "20px 25px", + }, + fv_sidebarContent_list: { + margin: "0", + fontSize: "14px", + "& > dt": { + color: neutralForegroundRest, + fontWeight: "600", + marginBottom: "4px", + }, + "& > dd": { + color: neutralForegroundHover, + fontWeight: "400", + marginLeft: "0", + marginBottom: "20px", + }, + }, +}; + +const FVSidebarContent: React.ComponentType = memo( + ({ managedClasses, fileData }) => { + const { t } = useTranslation("fileview"); + const uploaded = new Date(fileData.timestamp * 1000); + const title = fileData.title === "untitled" ? "-" : fileData.title; + + return ( +
+
+
{t("description")}:
+
{isLoggedIn ? : title}
+
{t("width")}:
+
{fileData.width || "-"}
+
{t("height")}:
+
{fileData.height || "-"}
+
{t("format")}:
+
{fileData.extension || "-"}
+
{t("uploaded")}:
+
+ {uploaded + ? `${uploaded.toLocaleDateString()} ${uploaded.toLocaleTimeString()}` + : "-"} +
+
+
+ ); + } +); + +export default manageJss(styles)(FVSidebarContent); diff --git a/src/FileView/views/FVSidebar/FVSidebarContext.props.ts b/src/FileView/views/FVSidebar/FVSidebarContext.props.ts new file mode 100755 index 0000000..e655573 --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebarContext.props.ts @@ -0,0 +1,31 @@ +import { MotionValue } from "framer-motion"; + +/** + * This context contains an array of useful + * positional data about the sidebar which can be + * subscribed to, so that affected elements can + * move along the sidebar smoothly. + */ +export interface ISidebarData { + /** + * The width of the sidebar + */ + sidebarWidth: MotionValue; + /** + * The current position of the sidebar. + * `sidebarPos` <= 0 := closed / `sidebarPos` > 0 := visible + * + * We assume that the sidebar cannot be opened + * in a size smaller than 100 + */ + sidebarPos: MotionValue; + /** + * True, already while it's opening. + * False, already while closing. + */ + isSidebarVisible: boolean; + setSidebarVisibility: React.Dispatch>; + isSidebarFloating: boolean; + fileTitle: string; + setFileTitle: React.Dispatch>; +} diff --git a/src/FileView/views/FVSidebar/FVSidebarContext.tsx b/src/FileView/views/FVSidebar/FVSidebarContext.tsx new file mode 100755 index 0000000..b2710f5 --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebarContext.tsx @@ -0,0 +1,43 @@ +import React, { useState, useMemo } from "react"; +import { useMotionValue } from "framer-motion"; +import { ISidebarData } from "./FVSidebarContext.props"; +import { useWindowBreakpoint } from "../../../_DesignSystem"; + +/** + * The width of the sidebar by default + */ +const defaultSidebarWidth: number = 400; + +/** + * A react context that broadcasts positional data like + * the position of the sidebar. + */ +export const SidebarData = React.createContext(null); + +const FVSidebarProvider: React.ComponentType<{ fileData: Window["fileData"] }> = ({ + children, + fileData, +}) => { + const sidebarWidth = useMotionValue(defaultSidebarWidth); + const sidebarPos = useMotionValue(0); + const [isSidebarVisible, setSidebarVisibility] = useState(false); + const isSidebarFloating = useWindowBreakpoint(850, "max-width", true); + const [fileTitle, setFileTitle] = useState(fileData ? fileData.title : ""); + + const sidebarValues = useMemo( + () => ({ + sidebarWidth, + sidebarPos, + isSidebarVisible, + setSidebarVisibility, + isSidebarFloating, + fileTitle, + setFileTitle, + }), + [fileTitle, isSidebarFloating, isSidebarVisible, sidebarPos, sidebarWidth] + ); + + return ; +}; + +export default FVSidebarProvider; diff --git a/src/FileView/views/FVSidebar/FVSidebarDeleteButton.tsx b/src/FileView/views/FVSidebar/FVSidebarDeleteButton.tsx new file mode 100755 index 0000000..49ceb90 --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebarDeleteButton.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { FVSidebarFooterProps } from "./FVSidebarFooter.props"; +import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; +import axios from "../../../_interceptedAxios"; +import { Button, ButtonAppearance, useToasts } from "../../../_DesignSystem"; +import { FaTrash } from "react-icons/fa"; + +/** + * Button in FVSidebar: Deletes the currently inspected file. + */ +const FVSidebarDeleteButton: React.ComponentType = ({ + fileData, +}) => { + const { t } = useTranslation("dashboard"); + const { addToast } = useToasts(); + const history = useHistory(); + + const onDelete = async () => { + try { + await axios.post(window.location.origin + "/api/edit.php", { + selection: fileData.id, + action: "delete", + }); + + addToast("", { + appearance: "success", + title: t("selectedDeleted", { count: 1 }), + }); + + history.replace("/"); + } catch (err) { + addToast(t(err.i18n, err.message), { + appearance: "error", + title: t("error.requestGeneric") + ":", + }); + console.log(`${t("error.requestGeneric")}:\n`, `(${err.code}) - ${err.message}`); + } + }; + + return ( + + ); +}; + +export default FVSidebarDeleteButton; diff --git a/src/FileView/views/FVSidebar/FVSidebarDescEditor.props.ts b/src/FileView/views/FVSidebar/FVSidebarDescEditor.props.ts new file mode 100755 index 0000000..d41bc6b --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebarDescEditor.props.ts @@ -0,0 +1,17 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface FVSidebarDescEditorClassNameContract { + fv_sidebar_descEditor: string; + fv_sidebar_descEditor_check?: string; +} + +/** + * Props for the component + */ +export interface FVSidebarDescEditorProps + extends ManagedClasses { + fileData: Window["fileData"]; +} diff --git a/src/FileView/views/FVSidebar/FVSidebarDescEditor.tsx b/src/FileView/views/FVSidebar/FVSidebarDescEditor.tsx new file mode 100755 index 0000000..2889a79 --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebarDescEditor.tsx @@ -0,0 +1,121 @@ +import React, { useState, useContext } from "react"; +import { + FVSidebarDescEditorProps, + FVSidebarDescEditorClassNameContract, +} from "./FVSidebarDescEditor.props"; +import { DesignSystem } from "@microsoft/fast-components-styles-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { + TextAction, + Progress, + ProgressClassNameContract, +} from "@microsoft/fast-components-react-msft"; +import { FaCheck, FaExclamationTriangle } from "react-icons/fa"; +import axios from "../../../_interceptedAxios"; +import { useToasts } from "../../../_DesignSystem"; +import { useTranslation } from "react-i18next"; +import { SidebarData } from "./FVSidebarContext"; + +const styles: ComponentStyles = { + "@keyframes fadeAway": { + from: { opacity: "1" }, + to: { opacity: "0" }, + }, + fv_sidebar_descEditor: { + marginTop: "5px", + }, + fv_sidebar_descEditor_check: { + animationName: "fadeAway", + animationDuration: "5s", + animationDelay: "2s", + animationFillMode: "forwards", + animationTimingFunction: "linear", + }, +}; + +const progressStyles: ComponentStyles = { + progress_circularSVG__container: { + width: "auto", + height: "auto", + }, + progress_circularSVG__control: {}, + progress_circularSVG__page: {}, +}; + +const FVSidebarDescEditor: React.ComponentType = ({ + managedClasses, + fileData, +}) => { + const { addToast } = useToasts(); + const { t } = useTranslation("common"); + const { setFileTitle, fileTitle } = useContext(SidebarData); + const [loadingState, setLoadingState] = useState< + "loading" | "success" | "error" | null + >(null); + + const onFocus = () => loadingState !== "error" && setLoadingState(null); + + /** + * Saves changes to title if user leaves the input element. + */ + const onBlur: React.FocusEventHandler = async ({ currentTarget }) => { + let { value } = currentTarget; + value = value.trim(); + + // Avoid saving the same + if (value === fileTitle) return; + + // If the request takes too long, should appear + const deferredLoading = setTimeout(() => setLoadingState("loading"), 500); + + try { + await axios.post(window.location.origin + "/api/edit.php", { + selection: fileData.id, + value, + action: "editTitle", + }); + + clearTimeout(deferredLoading); + setLoadingState("success"); + setFileTitle(value); + } catch (err) { + clearTimeout(deferredLoading); + setLoadingState("error"); + addToast(t(err.i18n, err.message), { + appearance: "error", + title: t("error.requestGeneric") + ":", + }); + console.log(`${t("error.requestGeneric")}:\n`, `(${err.code}) - ${err.message}`); + } + }; + + return ( + + loadingState === "loading" ? ( + + ) : loadingState === "success" ? ( + + ) : ( + loadingState === "error" && + ) + } + /> + ); +}; + +export default manageJss(styles)(FVSidebarDescEditor); diff --git a/src/FileView/views/FVSidebar/FVSidebarFooter.props.ts b/src/FileView/views/FVSidebar/FVSidebarFooter.props.ts new file mode 100755 index 0000000..a27273e --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebarFooter.props.ts @@ -0,0 +1,18 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface FVSidebarFooterClassNameContract { + fv_sidebarFooter: string; + fv_sidebarFooter_buttons: string; + fv_sidebarFooter_links: string; +} + +/** + * Props for the component + */ +export interface FVSidebarFooterProps + extends ManagedClasses { + fileData: Window["fileData"]; +} diff --git a/src/FileView/views/FVSidebar/FVSidebarFooter.tsx b/src/FileView/views/FVSidebar/FVSidebarFooter.tsx new file mode 100755 index 0000000..e56ed0b --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebarFooter.tsx @@ -0,0 +1,127 @@ +import React, { useContext } from "react"; +import { + FVSidebarFooterProps, + FVSidebarFooterClassNameContract, +} from "./FVSidebarFooter.props"; +import { + DesignSystem, + neutralForegroundRest, + neutralLayerL2, +} from "@microsoft/fast-components-styles-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { Button, ButtonAppearance, isLoggedIn } from "../../../_DesignSystem"; +import { FaDownload, FaExternalLinkSquareAlt } from "react-icons/fa"; +import { useTranslation } from "react-i18next"; +import { Hypertext } from "@microsoft/fast-components-react-msft"; +import { designSystemContext } from "@microsoft/fast-jss-manager-react/dist/context"; +import loadable from "@loadable/component"; + +const FVSidebarDeleteButton = loadable(() => import("./FVSidebarDeleteButton")); + +const styles: ComponentStyles = { + fv_sidebarFooter: { + display: "flex", + flexDirection: "column", + }, + fv_sidebarFooter_links: { + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "15px 15px 10px 15px", + flexWrap: "wrap", + "& > a::before": { + content: "'•'", + pointerEvents: "none", + paddingLeft: "5px", + paddingRight: "5px", + color: neutralForegroundRest, + borderBottom: "1px solid", + borderBottomColor: neutralLayerL2, + }, + "& > *:first-child::before": { + display: "none", + }, + }, + fv_sidebarFooter_buttons: { + display: "flex", + fontSize: "14px", + "& > button, & > a": { + background: "transparent", + fontWeight: "600", + flexGrow: "1", + padding: "45px 15px 45px 15px", + display: "flex", + flexDirection: "column", + alignContent: "center", + borderRadius: "0", + "& > svg": { + marginBottom: "12px", + marginLeft: "8px", + width: "25px", + height: "25px", + }, + }, + }, +}; + +// Other possible color for later use: +// #1399dc + +/** + * Footer part of FVSidebar. + * + * It consists of a Hyperlink and a Button row, + * former used to access generic routes, + * latter used to execute an action. + */ +const FVSidebarFooter: React.ComponentType = ({ + managedClasses, + fileData, +}) => { + const { t } = useTranslation(["fileview", "common"]); + const desCtx = useContext(designSystemContext) as DesignSystem; + + return ( +
+
+ + {t("common:hyperlinks.about")} + + + {t("common:hyperlinks.thirdParty")} + + + {t("common:hyperlinks.changeLanguage")} + +
+
+ {isLoggedIn && } + + +
+
+ ); +}; + +export default manageJss(styles)(FVSidebarFooter); diff --git a/src/FileView/views/FVSidebar/FVSidebarToggleButton.props.ts b/src/FileView/views/FVSidebar/FVSidebarToggleButton.props.ts new file mode 100755 index 0000000..e3c5cc9 --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebarToggleButton.props.ts @@ -0,0 +1,14 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface FVSidebarToggleButtonClassNameContract { + fv_sidebarButton: string; +} + +/** + * Props for the component + */ +export interface FVSidebarToggleButtonProps + extends ManagedClasses {} diff --git a/src/FileView/views/FVSidebar/FVSidebarToggleButton.tsx b/src/FileView/views/FVSidebar/FVSidebarToggleButton.tsx new file mode 100755 index 0000000..4514479 --- /dev/null +++ b/src/FileView/views/FVSidebar/FVSidebarToggleButton.tsx @@ -0,0 +1,132 @@ +import React, { useContext, useState, useEffect } from "react"; +import { + FVSidebarToggleButtonProps, + FVSidebarToggleButtonClassNameContract, +} from "./FVSidebarToggleButton.props"; +import { + DesignSystem, + applyPillCornerRadius, + neutralLayerL2, +} from "@microsoft/fast-components-styles-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { motion, useTransform } from "framer-motion"; +import { FaCaretLeft, FaInfo } from "react-icons/fa"; +import { SidebarData } from "./FVSidebarContext"; +import { parseColorHexRGBA } from "@microsoft/fast-colors"; +import { designSystemContext } from "@microsoft/fast-jss-manager-react/dist/context"; +import { ButtonClassNameContract } from "../../../_DesignSystem/Button/Button.props"; +import { Button } from "../../../_DesignSystem"; + +/** + * The position on the x-axis where the button is placed by default + */ +export const defaultButtonPos = 17; + +/** + * Styling for the component + */ +const styles: ComponentStyles = { + fv_sidebarButton: { + zIndex: "63", + position: "absolute", + right: defaultButtonPos + "px", + top: "15px", + ...applyPillCornerRadius(), + }, +}; + +/** + * Custom styling for sidebar toggle. + */ +const customCaretStyle: ComponentStyles = { + button: { + paddingTop: "6px", + paddingLeft: "6px", + margin: "0", + "& > svg": { + paddingBottom: "2px", + }, + "& > div": { + height: "17px", + }, + }, +}; + +const FVSidebarToggleButton: React.ComponentType = ({ + managedClasses, +}) => { + const { isSidebarVisible, setSidebarVisibility, sidebarPos } = useContext(SidebarData); + const [isButtonHover, setButtonHover] = useState(false); + const designCtx = useContext(designSystemContext) as DesignSystem; + + /** + * Moves the button into the sidebar when opened. + * Consists of buttonPos + buttonWidth + paddingLeft + */ + const buttonPosition = useTransform(sidebarPos, pos => + Math.min((pos - (defaultButtonPos + 64)) * -1, 0) + ); + + /** + * Change background-color if sidebarPos > defaultButtonPos + * The first color needs to be the same as the second one only in transparent. + */ + const buttonBackground = useTransform( + sidebarPos, + [defaultButtonPos, 82], + [ + parseColorHexRGBA(neutralLayerL2(designCtx) + "00").toStringWebRGBA(), + neutralLayerL2(designCtx), + ] + ); + + /** + * Rotate icon if sidebar is opened. + */ + const buttonIconRotation = useTransform(sidebarPos, [defaultButtonPos, 82], [0, 180]); + + /** + * Resetting hover state if `isSidebarVisible` state changes. + * This is for avoiding visual bugs. + */ + useEffect(() => setButtonHover(false), [isSidebarVisible]); + + return ( + setButtonHover(true)} + onHoverEnd={() => setButtonHover(false)} + > + +
+ + ); + }; +}; + +export const withDropzone = ( + WrappedComponent: React.ComponentClass | React.FunctionComponent +) => manageJss(FullscreenDropzoneStyles)(FullscreenDropzone(WrappedComponent)); diff --git a/src/FullscreenDropzone/views/DropzoneDrag.props.ts b/src/FullscreenDropzone/views/DropzoneDrag.props.ts new file mode 100755 index 0000000..b0953cb --- /dev/null +++ b/src/FullscreenDropzone/views/DropzoneDrag.props.ts @@ -0,0 +1,15 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface DropzoneDragClassNameContract { + dropzoneDrag: string; + dropzoneDragIcon: string; +} + +/** + * Props for the component + */ +export interface DropzoneDragProps + extends ManagedClasses {} diff --git a/src/FullscreenDropzone/views/DropzoneDrag.tsx b/src/FullscreenDropzone/views/DropzoneDrag.tsx new file mode 100755 index 0000000..5bd64ee --- /dev/null +++ b/src/FullscreenDropzone/views/DropzoneDrag.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { FaImages } from "react-icons/fa"; +import { useTranslation } from "react-i18next"; +import { Heading, HeadingTag } from "@microsoft/fast-components-react-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { DropzoneDragClassNameContract, DropzoneDragProps } from "./DropzoneDrag.props"; +import { + neutralForegroundRest, + DesignSystem, + backgroundColor, +} from "@microsoft/fast-components-styles-msft"; +import { applyCenteredFlexbox } from "../../_DesignSystem"; + +const styles: ComponentStyles = { + dropzoneDrag: { + position: "absolute", + width: "100%", + height: "100%", + boxSizing: "border-box", + top: "0", + left: "0", + textAlign: "center", + padding: "21px 24px", + background: backgroundColor, + borderRadius: "10px", + zIndex: "100", + ...applyCenteredFlexbox(), + }, + dropzoneDragIcon: { + fontSize: "4em", + color: (designSystem: DesignSystem): string => neutralForegroundRest(designSystem), + }, +}; + +const DropzoneDrag = (props: DropzoneDragProps) => { + const { t } = useTranslation("dashboard"); + return ( +
+ + + {t("upload.dropTitle")} + + + {t("upload.dropDescription")} + +
+ ); +}; + +export default manageJss(styles)(DropzoneDrag); diff --git a/src/FullscreenDropzone/views/DropzoneError.props.ts b/src/FullscreenDropzone/views/DropzoneError.props.ts new file mode 100755 index 0000000..842f15c --- /dev/null +++ b/src/FullscreenDropzone/views/DropzoneError.props.ts @@ -0,0 +1,17 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface DropzoneErrorClassNameContract { + dropzoneError?: string; + dropzoneErrorIcon?: string; +} + +/** + * Props for the component + */ +export interface DropzoneErrorProps + extends ManagedClasses { + error: string; +} diff --git a/src/FullscreenDropzone/views/DropzoneError.tsx b/src/FullscreenDropzone/views/DropzoneError.tsx new file mode 100755 index 0000000..7aace7b --- /dev/null +++ b/src/FullscreenDropzone/views/DropzoneError.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { FaExclamationTriangle } from "react-icons/fa"; +import { useTranslation } from "react-i18next"; +import { Heading, HeadingTag } from "@microsoft/fast-components-react-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { + DropzoneErrorClassNameContract, + DropzoneErrorProps, +} from "./DropzoneError.props"; +import { + neutralForegroundRest, + DesignSystem, +} from "@microsoft/fast-components-styles-msft"; + +const styles: ComponentStyles = { + dropzoneErrorIcon: { + fontSize: "4em", + color: (designSystem: DesignSystem): string => neutralForegroundRest(designSystem), + }, +}; + +const DropzoneError = (props: DropzoneErrorProps) => { + const { t } = useTranslation("dashboard"); + return ( + + + + {t("upload.error.title")} + + + {props.error} + + + ); +}; + +export default manageJss(styles)(DropzoneError); diff --git a/src/FullscreenDropzone/views/DropzoneUpload.props.ts b/src/FullscreenDropzone/views/DropzoneUpload.props.ts new file mode 100755 index 0000000..6f69193 --- /dev/null +++ b/src/FullscreenDropzone/views/DropzoneUpload.props.ts @@ -0,0 +1,25 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface DropzoneUploadClassNameContract { + dropzoneUpload?: string; + dropzoneUpload_image?: string; + dropzoneUpload_details?: string; + dropzoneUpload_error?: string; + dropzoneUpload_progress?: string; + dropzoneUpload_icon?: string; +} + +/** + * Props for the component + */ +export interface DropzoneUploadProps + extends ManagedClasses { + key: string; + file: File; + rejected: boolean; + preview?: string; + onRemoveRequest?: () => void; +} diff --git a/src/FullscreenDropzone/views/DropzoneUpload.tsx b/src/FullscreenDropzone/views/DropzoneUpload.tsx new file mode 100755 index 0000000..29791b4 --- /dev/null +++ b/src/FullscreenDropzone/views/DropzoneUpload.tsx @@ -0,0 +1,183 @@ +import React, { useState, useEffect, useCallback, Fragment } from "react"; +import { FaExclamation, FaCheck } from "react-icons/fa"; +import { useTranslation } from "react-i18next"; +import { + Progress, + Label, + ProgressClassNameContract, + Hypertext, +} from "@microsoft/fast-components-react-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { + DropzoneUploadClassNameContract, + DropzoneUploadProps, +} from "./DropzoneUpload.props"; +import { + neutralForegroundRest, + DesignSystem, + neutralLayerCard, + applyAcrylicMaterial, + applyElevation, + ElevationMultiplier, + backgroundColor, +} from "@microsoft/fast-components-styles-msft"; +import { parseColorHexRGBA } from "@microsoft/fast-colors"; +import { ProgressIcon } from "../../_DesignSystem"; +import axios from "../../_interceptedAxios"; + +const DropzoneUploadStyles: ComponentStyles< + DropzoneUploadClassNameContract, + DesignSystem +> = { + dropzoneUpload: { + position: "relative", + overflow: "hidden", + width: "200px", + height: "200px", + background: neutralLayerCard, + ...applyElevation(ElevationMultiplier.e5), + "&:hover > footer": { + transform: "translateY(0%)", + }, + }, + dropzoneUpload_image: { + objectFit: "contain", + }, + dropzoneUpload_details: { + position: "absolute", + bottom: "0", + left: "0", + width: "100%", + padding: "10px 50px 10px 10px", + boxSizing: "border-box", + display: "flex", + textAlign: "left", + lineBreak: "anywhere", + transform: "translateY(100%)", + transition: "transform .15s", + minHeight: "57px", + ...applyAcrylicMaterial("#000000", 0.8), + background: des => parseColorHexRGBA(backgroundColor(des) + "cc").toStringWebRGBA(), + "&.focus-visible, &:focus-within": { + transform: "translateY(0%)", + }, + }, + dropzoneUpload_progress: { + position: "absolute", + bottom: "10px", + right: "10px", + }, + dropzoneUpload_error: { + lineBreak: "initial", + }, + dropzoneUploadIcon: { + fontSize: "4em", + color: neutralForegroundRest, + }, +}; + +const ProgressStyles: ComponentStyles = { + progress_circularSVG__container: { + ...applyElevation(ElevationMultiplier.e8), + borderRadius: "50%", + }, + progress_circularSVG__control: {}, + progress_circularSVG__page: {}, +}; + +const DropzoneUpload = (props: DropzoneUploadProps) => { + const { t } = useTranslation("dashboard"); + const [errorMessage, setErrorMessage] = useState(""); + const [progress, setProgress] = useState(0); + const [resData, setResData] = useState({ id: null, file_url: null }); + + const onUploadProgress = (prog: ProgressEvent) => + setProgress(Math.round((prog.loaded * 100) / prog.total)); + + const uploadFile = useCallback(async () => { + const formData = new FormData(); + formData.set("data", props.file); + + try { + const res = await axios.post(window.location.origin + "/api/upload.php", formData, { + onUploadProgress, + }); + setResData(res.data); + } catch (err) { + console.log("An error happened!\n", err); + setErrorMessage(err.i18n ? t(err.i18n) : err.message || "Unknkown Error!"); + return null; + } + }, [props.file, t]); + + // onMount + useEffect(() => { + console.log("updated!"); + if (props.rejected) { + let savedTimeout = setTimeout(props.onRemoveRequest, 6000); + return () => clearTimeout(savedTimeout); + } + + // Uploading process + if (resData.id || errorMessage) return () => {}; + uploadFile(); + return () => {}; + }, [errorMessage, props.onRemoveRequest, props.rejected, resData.id, uploadFile]); + + return ( +
+ {!props.rejected && props.preview && ( + {props.file.name} + )} +
+ +
+
+ {props.rejected || errorMessage.length > 0 ? ( + + ) : progress === 100 ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default manageJss(DropzoneUploadStyles)(DropzoneUpload); diff --git a/src/FullscreenDropzone/views/DropzoneUploadManager.props.ts b/src/FullscreenDropzone/views/DropzoneUploadManager.props.ts new file mode 100755 index 0000000..9ac71ef --- /dev/null +++ b/src/FullscreenDropzone/views/DropzoneUploadManager.props.ts @@ -0,0 +1,20 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface DropzoneUploadManagerClassNameContract { + dropzoneUploadManager: string; + dropzoneUploadManager_hidden: string; +} + +/** + * Props for the component + */ +export interface DropzoneUploadManagerProps + extends ManagedClasses { + dropData: { + acceptedFiles: File[]; + rejectedFiles: File[]; + }; +} diff --git a/src/FullscreenDropzone/views/DropzoneUploadManager.tsx b/src/FullscreenDropzone/views/DropzoneUploadManager.tsx new file mode 100755 index 0000000..601ea48 --- /dev/null +++ b/src/FullscreenDropzone/views/DropzoneUploadManager.tsx @@ -0,0 +1,121 @@ +import React, { useState, useEffect, useCallback } from "react"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { + DropzoneUploadManagerClassNameContract, + DropzoneUploadManagerProps, +} from "./DropzoneUploadManager.props"; +import { DesignSystem } from "@microsoft/fast-components-styles-msft"; +import DropzoneUpload from "./DropzoneUpload"; +import { uniqueId } from "lodash-es"; + +const styles: ComponentStyles = { + dropzoneUploadManager: { + display: "flex", + flexWrap: "wrap", + overflow: "auto", + justifyContent: "space-around", + "& > *": { + marginBottom: "20px", + }, + }, + dropzoneUploadManager_hidden: { + display: "none", + }, +}; + +const DropzoneUploadManager = (props: DropzoneUploadManagerProps) => { + const [dropData, setDropData] = useState(props.dropData); + const [uploadList, changeUploadList] = useState([]); + + const manageDropDataChange = useCallback(() => { + const { dropData } = props; + if (dropData.acceptedFiles.length <= 0) { + console.log("bingo"); + } + + console.log("manageDropDataChange"); + const acceptedUploadList = dropData.acceptedFiles.map(file => { + return { + key: uniqueId(new Date().getTime() + ""), + file, + rejected: false, + preview: URL.createObjectURL(file), + }; + }); + + const rejectedUploadList = dropData.rejectedFiles.map(file => { + return { + key: uniqueId(""), + file, + rejected: true, + }; + }); + + const updatedUploadList = [ + ...uploadList, + ...acceptedUploadList, + ...rejectedUploadList, + ]; + console.log(updatedUploadList); + changeUploadList(updatedUploadList); + }, [props, uploadList]); + + const removeUploadItem = useCallback( + (key: string) => () => + changeUploadList(prevUploadList => { + const updatedUploadList = prevUploadList.filter( + (obj: { key: String }) => obj.key !== key + ); + return updatedUploadList; + }), + [] + ); + + // + + // Props change listener + useEffect(() => { + if (props.dropData !== dropData) { + setDropData(props.dropData); + manageDropDataChange(); + } + }, [props.dropData, dropData, manageDropDataChange]); + + return ( +
+ {uploadList.length > 0 && + uploadList.map( + (obj: { + key: string; + file: File; + rejected: boolean; + preview: string | null; + }) => ( + + ) + )} +
+ ); + /* + return ( + (dropData.acceptedFiles.length > 0 || dropData.rejectedFiles.length > 0) && ( + + + + {t("upload.error.title")} + + + {errorMessage} + + + ) + );*/ +}; + +export default manageJss(styles)(DropzoneUploadManager); diff --git a/src/Loader/FullscreenLoader.props.ts b/src/Loader/FullscreenLoader.props.ts new file mode 100755 index 0000000..4a74631 --- /dev/null +++ b/src/Loader/FullscreenLoader.props.ts @@ -0,0 +1,17 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the fsLoader component + */ +export interface FullscreenLoadingIndicatorClassNameContract { + /** + * Root of the fsLoader component + */ + fsLoaderBackdrop: string; +} + +/** + * Props for the fsLoader component + */ +export interface FullscreenLoadingIndicatorProps + extends ManagedClasses {} diff --git a/src/Loader/FullscreenLoader.tsx b/src/Loader/FullscreenLoader.tsx new file mode 100755 index 0000000..e4439ca --- /dev/null +++ b/src/Loader/FullscreenLoader.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import loadable from "@loadable/component"; +import { + FullscreenLoadingIndicatorProps, + FullscreenLoadingIndicatorClassNameContract, +} from "./FullscreenLoader.props"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { DesignSystem } from "@microsoft/fast-components-styles-msft"; +import { ColorRGBA64 } from "@microsoft/fast-colors"; + +const styles: ComponentStyles< + FullscreenLoadingIndicatorClassNameContract, + DesignSystem +> = { + fsLoaderBackdrop: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + position: "fixed", + background: new ColorRGBA64(0, 0, 0, 0.6).toStringWebRGBA(), + width: "100%", + height: "100%", + zIndex: "100", + color: "white", + "& span": { + "font-size": "2em", + }, + }, +}; + +class FullscreenLoadingIndicator extends React.Component< + FullscreenLoadingIndicatorProps +> { + render(): JSX.Element { + return ( +
+ Loading... +
+ ); + } +} + +const StyledFullscreenLoadingIndicator = manageJss(styles)(FullscreenLoadingIndicator); + +export const FullscreenLoader = (promise: Promise) => + loadable(() => promise, { + fallback: , + }); diff --git a/src/Loader/index.ts b/src/Loader/index.ts new file mode 100755 index 0000000..95fd8a5 --- /dev/null +++ b/src/Loader/index.ts @@ -0,0 +1 @@ +export * from "./FullscreenLoader"; diff --git a/src/Login/Login.props.ts b/src/Login/Login.props.ts new file mode 100755 index 0000000..b7f54fd --- /dev/null +++ b/src/Login/Login.props.ts @@ -0,0 +1,25 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface LoginClassNameContract { + login: string; + login_form: string; +} + +/** + * Props for the component + */ +export interface LoginProps extends ManagedClasses {} + +/** + * Custom change event, since all targets are inputs + * + * @export + * @interface LoginInputChangeEvent + * @extends {React.ChangeEvent} + */ +export interface LoginInputChangeEvent extends React.ChangeEvent { + target: EventTarget & HTMLInputElement; +} diff --git a/src/Login/Login.tsx b/src/Login/Login.tsx new file mode 100755 index 0000000..dfde6fd --- /dev/null +++ b/src/Login/Login.tsx @@ -0,0 +1,104 @@ +import React, { Fragment, useState } from "react"; +import { Button, ButtonAppearance, Logo, iconToGlyph } from "../_DesignSystem"; +import { DesignSystem } from "@microsoft/fast-components-styles-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { LoginClassNameContract, LoginProps } from "./Login.props"; +import { FaSignInAlt, FaUserAlt, FaKey } from "react-icons/fa"; +import { TextAction, TextFieldType } from "@microsoft/fast-components-react-msft"; +import axios from "../_interceptedAxios"; +import { useToasts } from "../_DesignSystem"; +import { useTranslation } from "react-i18next"; + +const styles: ComponentStyles = { + login: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + }, + login_form: { + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + "margin-top": "15px", + + "& > *": { + width: "100%", + "margin-bottom": "8px", + }, + "& > *:nth-child(2)": { + "margin-bottom": "15px", + }, + }, +}; + +const Login: React.FC = props => { + const { t, i18n } = useTranslation("common"); + const { addToast } = useToasts(); + const [isDebounced, setDebounce] = useState(false); + + /** + * Handles user submitting their login credentials. + * Since each form input has a name assigned, the FormData may derive from the
Element. + * + * @memberof Login + */ + const onFormSubmit = (e: React.FormEvent): void => { + e.preventDefault(); + + const postOperation = async (e: React.FormEvent): Promise => { + const formData = new FormData(e.currentTarget); + try { + await axios.post(window.location.href + "api/login.php", formData); + window.location.reload(); + } catch (err) { + setTimeout(() => { + setDebounce(false); + addToast(i18n.t(err.i18n, err.message), { + appearance: "error", + title: i18n.t("error.loginGeneric") + ":", + }); + console.error("User could not log in:\n", `(${err.code}) - ${err.message}`); + }, 1000); + } + }; + + if (isDebounced) return; + setDebounce(true); + postOperation(e); + }; + + return ( + +
+ + + + + + +
+
+ ); +}; + +export default manageJss(styles)(Login); diff --git a/src/NotFound/NotFound.props.ts b/src/NotFound/NotFound.props.ts new file mode 100755 index 0000000..16819f7 --- /dev/null +++ b/src/NotFound/NotFound.props.ts @@ -0,0 +1,15 @@ +import { ManagedClasses } from "@microsoft/fast-jss-manager-react"; + +/** + * Class name contract for the component + */ +export interface NotFoundClassNameContract { + notFound?: string; + notFound_container?: string; + notFound_icon?: string; +} + +/** + * Props for the component + */ +export interface NotFoundProps extends ManagedClasses {} diff --git a/src/NotFound/NotFound.tsx b/src/NotFound/NotFound.tsx new file mode 100755 index 0000000..edfd6f3 --- /dev/null +++ b/src/NotFound/NotFound.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { Heading, HeadingTag } from "@microsoft/fast-components-react-msft"; +import { + DesignSystem, + neutralForegroundRest, +} from "@microsoft/fast-components-styles-msft"; +import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react"; +import { NotFoundClassNameContract, NotFoundProps } from "./NotFound.props"; +import { Header } from "../_DesignSystem"; +import { FaHeartBroken } from "react-icons/fa"; +import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; + +const styles: ComponentStyles = { + notFound: { + display: "flex", + flexDirection: "column", + position: "fixed", + width: "100%", + height: "100%", + }, + notFound_container: { + flexGrow: "1", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + }, + notFound_icon: { + fontSize: "4em", + color: neutralForegroundRest, + }, +}; + +const NotFound: React.ComponentType = ({ managedClasses }) => { + const { t } = useTranslation("common"); + + return ( + +
+
+ + + {t("notFound.title")} + + + {t("notFound.description")} + +
+ + ); +}; + +export default manageJss(styles)(NotFound); diff --git a/src/_DesignSystem/Button/Button.props.ts b/src/_DesignSystem/Button/Button.props.ts new file mode 100755 index 0000000..b7c82ab --- /dev/null +++ b/src/_DesignSystem/Button/Button.props.ts @@ -0,0 +1,54 @@ +import { IconType } from "react-icons/lib"; +import { Omit } from "utility-types"; +import { + ButtonClassNameContract as MSFTButtonClassNameContract, + ManagedClasses, +} from "@microsoft/fast-components-class-name-contracts-msft"; +import { + ButtonHandledProps as MSFTButtonHandledProps, + ButtonUnhandledProps as MSFTButtonUnhandledProps, +} from "@microsoft/fast-components-react-msft/dist/button/button.props"; + +/** + * The class name contract for the action trigger component + */ +export interface ButtonClassNameContract extends MSFTButtonClassNameContract { + /** + * Icon className + */ + button_icon?: string; + + /** + * Class name used when checking icon and content + */ + button__hasIconAndContent?: string; + + /** + * Class name used when there is no other content but the icon + */ + button__iconOnly?: string; +} + +export interface ButtonManagedClasses extends ManagedClasses {} +export interface ButtonHandledProps + extends Omit, + ButtonManagedClasses { + /** + * The action trigger glyph render prop + */ + icon?: IconType; + + /** + * The action trigger link address + */ + href?: string; + + /** + * The action trigger disabled property + */ + disabled?: boolean; +} + +/* tslint:disable-next-line:no-empty-interface */ +export interface ButtonUnhandledProps extends MSFTButtonUnhandledProps {} +export type ButtonProps = ButtonHandledProps & ButtonUnhandledProps; diff --git a/src/_DesignSystem/Button/Button.tsx b/src/_DesignSystem/Button/Button.tsx new file mode 100755 index 0000000..34b9a14 --- /dev/null +++ b/src/_DesignSystem/Button/Button.tsx @@ -0,0 +1,111 @@ +/** + * Code taken from "@microsoft/fast-components-react-msft" + * + * This component is similar to the original