diff --git a/.DS_Store b/.DS_Store index 3e391de..b1bf7e5 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..0d465e9 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,19 @@ +{ + "extends": "react-app", + "rules": { + "react/jsx-no-bind": [ + "error", + { + "allowArrowFunctions": true, + "allowBind": false, + "ignoreRefs": true + } + ], + "react/no-did-update-set-state": "error", + "react/no-unknown-property": "error", + "react/no-unused-prop-types": "error", + "react/prop-types": "off", + "react/react-in-jsx-scope": "error", + "react/self-closing-comp": "error" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79bb4a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# development +/dist +*.old.* + +# custom assets +/src/_DesignSystem/Assets +/public/protected/config.php + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +/.vscode \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..623b3d7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "submodules/getID3"] + path = submodules/getID3 + url = https://github.com/JamesHeinrich/getID3 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..bbf2b15 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "printWidth": 90, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "jsxBracketSameLine": false, + "arrowParens": "avoid" +} diff --git a/README.md b/README.md index 0865b4f..65593e5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,33 @@ # Shadis -ShareX Distribution System -TODO: Better README + +This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). + +# Development + +This project uses React for the front-end and PHP for the back-end. You can find the front-end code in `src` and the back-end code in `public`. + +## Available Scripts + +In the project directory, you can run: + +### `yarn start` or `yarn serve` + +Runs the app in the development mode and builds it to the `dist` folder.
+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 0000000..a11777c Binary files /dev/null and b/public/static/media/favicon.ico differ diff --git a/public/static/media/logo192.png b/public/static/media/logo192.png new file mode 100755 index 0000000..fc44b0a Binary files /dev/null and b/public/static/media/logo192.png differ diff --git a/public/static/media/logo512.png b/public/static/media/logo512.png new file mode 100755 index 0000000..a4e47a6 Binary files /dev/null and b/public/static/media/logo512.png differ 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