+ CRA rewired for PHP support

This commit is contained in:
Pogodaanton 2020-05-09 21:56:02 +02:00
parent a02d7c7612
commit e4be2cbba3
118 changed files with 18709 additions and 2 deletions

BIN
.DS_Store vendored

Binary file not shown.

19
.eslintrc Normal file
View File

@ -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"
}
}

32
.gitignore vendored Normal file
View File

@ -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

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "submodules/getID3"]
path = submodules/getID3
url = https://github.com/JamesHeinrich/getID3

11
.prettierrc Normal file
View File

@ -0,0 +1,11 @@
{
"printWidth": 90,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"arrowParens": "avoid"
}

View File

@ -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.<br />
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.<br />
You will also see any lint errors in the console.
### `yarn build`
Builds the app for production to the `build` folder.<br />
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.<br />
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/).

72
package.json Normal file
View File

@ -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"
]
}
}

33
public/.htaccess Executable file
View File

@ -0,0 +1,33 @@
ErrorDocument 404 /
ErrorDocument 403 /
IndexIgnore *
<IfModule mod_rewrite.c>
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]
</IfModule>
<IfModule mod_deflate.c>
<filesMatch "\.(js|css|html|php)$">
SetOutputFilter DEFLATE
</filesMatch>
</IfModule>

117
public/api/edit.php Executable file
View File

@ -0,0 +1,117 @@
<?php
require_once "../protected/db.inc.php";
require_once "../protected/output.inc.php";
session_start();
// Request can only be GET
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
error("Only POST request allowed!", 401);
}
// Rewrap data assuming it is a JSON request
if (empty($_POST)) {
$_POST = json_decode(file_get_contents("php://input"));
if (json_last_error() !== JSON_ERROR_NONE) {
error("Could note decode JSON data: (" . json_last_error() . ") " . json_last_error_msg());
}
}
$token = $_POST->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!");

21
public/api/get.php Executable file
View File

@ -0,0 +1,21 @@
<?php
require_once "../protected/db.inc.php";
require_once "../protected/output.inc.php";
session_start();
// Request can only be GET
if ($_SERVER["REQUEST_METHOD"] !== "GET") {
error("Only GET request allowed!", 401);
}
// Make sure arguments exist
$id = $_GET["id"];
if (!isset($id)) {
error("Missing argument \"id\".", 401);
}
$result = $db->request_file($id);
header("Content-type: application/json");
echo json_encode($result);

26
public/api/getAll.php Executable file
View File

@ -0,0 +1,26 @@
<?php
require_once "../protected/db.inc.php";
require_once "../protected/output.inc.php";
session_start();
// Request can only be GET
if ($_SERVER["REQUEST_METHOD"] !== "GET") {
error("Only GET request allowed!", 401);
}
// Login check
if (!isset($_SESSION["u_id"])) {
error("Unauthorized", 401);
}
// Retrieve everything above this timestamp
$since = $_GET["since"];
if (isset($since)) {
$result = $db->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));

28
public/api/login.php Executable file
View File

@ -0,0 +1,28 @@
<?php
session_start();
require_once "../protected/db.inc.php";
require_once "../protected/output.inc.php";
$username = $_POST["username"];
$password = $_POST["password"];
if (!empty($_SESSION["u_id"])) {
error("You are already logged in!");
}
if (empty($username) || empty($password)) {
error("Missing username or password!", 400, "error.loginMissingData");
}
$result = $db->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();

6
public/api/logout.php Executable file
View File

@ -0,0 +1,6 @@
<?php
session_start();
session_unset();
session_destroy();
header('Location: ../');
exit();

40
public/api/upload.php Executable file
View File

@ -0,0 +1,40 @@
<?php
require_once "../protected/config.php";
require_once "../protected/output.inc.php";
session_start();
$loggedIn = isset($_SESSION["u_id"]);
$secret = $_POST["secret"];
$file = $_FILES["data"];
$supportedImageFormats = "/(png|jpeg|gif)/";
// Check if key set or logged in
if ((!isset($secret) || $secret !== UPLOAD_TOKEN) && !$loggedIn) {
error("Unauthorized", 401);
}
// Size must not exceed 2gb
if ($file["size"] > 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);

99
public/index.php Executable file
View File

@ -0,0 +1,99 @@
<?php
session_start();
$uri_path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$segments = explode('/', trim($uri_path, '/'));
$file_data = null;
$title = "Shadis";
if (!empty($segments[0])) {
if (strlen($segments[0]) === 8) {
require_once "./protected/db.inc.php";
require_once "./protected/output.inc.php";
$file_data = $db->request_file($segments[0]);
$title = ($file_data["title"] !== "" ? ($file_data["title"] . " - ") : "") . $file_data["id"] . " - Shadis";
}
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/static/media/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta content="noindex" name="robots">
<link rel="apple-touch-icon" href="%PUBLIC_URL%/static/media/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Shadis</title>
<meta name="title" content="<?php echo $title; ?>">
<meta name="description" content="Share and host your favourite screenshots and screencaptures on your own server!">
<?php
if (isset($_SESSION["u_id"])) {
$user_data = array("username" => $_SESSION["u_name"]);
echo '<script>var userData = ';
echo json_encode($user_data);
echo '</script>';
}
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 '<script>var fileData = ';
echo json_encode($file_data);
echo '</script>';
?>
<style>
#preContainer {
position: fixed;
padding-top: 64px;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
z-index: 60;
}
#preContainer img {
width: 100%;
height: 100%;
object-fit: scale-down;
}
</style>
<link rel="image_src" href="<?php echo $file_url; ?>">
<!-- Facebook OpenGraph Metadata -->
<meta property="og:title" content="Shadis">
<meta property="og:site_name" content="Shadis">
<meta property="og:description" content="<?php echo $file_data["title"]; ?>">
<meta property="og:image" content="<?php echo $file_url; ?>">
<meta property="og:image:width" content="<?php echo $file_data["width"]; ?>">
<meta property="og:image:height" content="<?php echo $file_data["height"]; ?>">
<meta property="og:url" content="<?php echo $origin_url . "/" . $file_data["id"] . "/"; ?>">
<!-- Twitter Metadata -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Image on Shadis">
<meta name="twitter:description" content="<?php echo $file_data["title"]; ?>">
<meta name="twitter:image" content="<?php echo $file_url; ?>">
<?php endif; ?>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<?php if (!is_null($file_data)) : ?>
<div id="preContainer">
<img src="<?php echo $file_url; ?>" />
</div>
<?php endif; ?>
</body>
</html>

25
public/manifest.json Executable file
View File

@ -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"
}

View File

@ -0,0 +1,57 @@
<?php
/**
* MySQL hostname
*/
define("DB_HOST", "your_host_adress_here");
/**
* MySQL database username
*/
define("DB_USER", "your_username_here");
/**
* MySQL database password
*/
define("DB_PASSWORD", "your_password_here");
/**
* The name of the database for Shadis
* Accepts only numbers, letters, and underscores
*/
define("DB_NAME", "your_database_name_here");
/**
* Database Charset to use in creating database tables.
*/
define("DB_CHARSET", "utf8");
/**
* Token used for uploading if you are not logged in
*/
define("UPLOAD_TOKEN", "your_secret_upload_token_here");
/**
* Shadis Database Table prefix.
* Accepts only numbers, letters, and underscores
*/
$table_prefix = "shadis_";
/**
* Base directory of Shadis
*
* If you put Shadis in a location other than root,
* modify this variable to the specific subdirectory.
*/
$base_directory = "/";
/**
* Path to ImageMagick executable
* Make sure the given path is really the right one.
*
* Since the binary is called differently on macOS
* than on other platforms, you also need to append
* the name of the executable.
*
*/
$imagick_path = "/usr/local/bin/magick";

101
public/protected/db.inc.php Executable file
View File

@ -0,0 +1,101 @@
<?php
require_once "config.php";
require_once "output.inc.php";
/**
* Establishes a database connection
* and provides helper functions for db communication
*
* @var $con mysqli Established MySQL connection
*/
class db
{
public $con;
public function __construct()
{
$con = mysqli_connect(DB_HOST, DB_USER, DB_PASSWORD, DB_NAME);
if (mysqli_connect_error() || !$con) {
error("Failed to connect to MySQL: " . mysqli_connect_error());
}
$this->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();

43
public/protected/output.inc.php Executable file
View File

@ -0,0 +1,43 @@
<?php
require_once "config.php";
/**
* Dies with a message in JSON format.
*
* @param string $message A custom message
* @param int $code An http code to respond with
*/
function respond($message = "Request successful", $code = 200, $i18n_id = "")
{
header("Content-type: application/json");
http_response_code($code);
die(json_encode(array("code" => $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;
}

View File

@ -0,0 +1,216 @@
<?php
require_once dirname(__FILE__) . "/../db.inc.php";
require_once dirname(__FILE__) . "/../output.inc.php";
$base_folder = dirname(__FILE__) . "/../../uploads/";
class ImageUpload
{
public $file;
public $file_name;
public $file_extension;
public $file_width;
public $file_height;
public $thumbnail_height;
public $timestamp;
public $file_title;
public $file_id;
public $file_token;
public $db;
public function __construct($file, $title, $timestamp)
{
$this->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
);
}
}

3
public/robots.txt Executable file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow: /

View File

@ -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."
}
}

View File

@ -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"
}
}

View File

@ -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"
}

View File

@ -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."
}
}

View File

@ -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"
}
}

View File

@ -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"
}

BIN
public/static/media/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/static/media/logo192.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/static/media/logo512.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

117
scripts/config-overrides.js Normal file
View File

@ -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"),
});
},
};

166
scripts/watch.js Normal file
View File

@ -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,
});
}

127
src/App/AnimatedRoutes.tsx Executable file
View File

@ -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<FileViewProps> = 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<any> = loadable(() =>
import(/* webpackChunkName: "Dashboard" */ "../Dashboard/Dashboard")
);
/**
* Prefetcher for <FileView/>
*
* 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<Window["fileData"]>(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<RouteChildrenProps<{ id: string }>> = ({
match,
history,
}) => {
const ViewRef = useRef<React.ComponentType<FileViewProps>>(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 (
<AnimateSharedLayout type="crossfade">
{isDashboardVisible && <Dashboard key="dashboard" frozen={!!match.params.id} />}
<AnimatePresence exitBeforeEnter>
{isValidFile && <ViewRef.current fileData={fileData} key={match.params.id} />}
{is404 && <NotFound key="notFound" />}
{!match.params.id && !isLoggedIn && <Login key="login" />}
</AnimatePresence>
</AnimateSharedLayout>
);
};
export default AnimatedRoutes;

17
src/App/App.props.ts Executable file
View File

@ -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<AppContainerClassNameContract> {}

31
src/App/App.tsx Executable file
View File

@ -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<AppContainerClassNameContract, DesignSystem> = {
container: {
display: "flex",
width: "100%",
minHeight: "100%",
flexDirection: "column",
"& > div": {
flexGrow: "1",
},
},
};
const AppContainer: React.ComponentType<AppContainerProps> = ({ managedClasses }) => (
<Background className={managedClasses.container} value={neutralLayerL1}>
<Router>
<Suspense fallback={null}>
<Route path={["/:id", "/"]} component={AnimatedRoutes} />
</Suspense>
</Router>
</Background>
);
export default manageJss(styles)(AppContainer);

View File

@ -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<DashboardClassNameContract> {
frozen?: boolean;
}

95
src/Dashboard/Dashboard.tsx Executable file
View File

@ -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<DashboardListProps> = FullscreenLoader(
import("../DashboardList/DashboardList")
);
const styles: ComponentStyles<DashboardClassNameContract, DesignSystem> = {
dashboard__frozen: {
pointerEvents: "none",
overflow: "hidden",
},
};
const Dashboard: React.FC<DashboardProps> = props => {
const [listData, setListData] = useState<ListDataItem[]>(null);
const [isFrozen, setFrozenState] = useState<boolean>(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 (
<div className={isFrozen ? props.managedClasses.dashboard__frozen : ""}>
<Header position="fixed" />
{listData === null ? null : listData.length === 0 ? (
<DashboardEmpty />
) : (
<DashboardList
listData={listData}
onDeleteSelected={onDeleteSelected}
frozen={props.frozen}
/>
)}
</div>
);
};
export default withDropzone(manageJss(styles)(Dashboard));

View File

@ -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<DashboardEmptyClassNameContract> {}

View File

@ -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<DashboardEmptyClassNameContract, DesignSystem> = {
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<DashboardEmptyProps> = React.memo(props => {
const { t } = useTranslation("dashboard");
return (
<div className={props.managedClasses.dashboardEmpty}>
<FaFolderOpen className={props.managedClasses.dashboardEmptyIcon} />
<Heading tag={HeadingTag.h1} size={2}>
{t("emptyTitle")}
</Heading>
<Heading tag={HeadingTag.h2} size={5}>
{t("emptyDescription")}
</Heading>
</div>
);
});
export default manageJss(styles)(DashboardEmpty);

View File

@ -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<DashboardListClassNameContract> {
listData: ListDataItem[];
onDeleteSelected: (selectedItems: string[]) => void;
frozen?: boolean;
}

View File

@ -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<DashboardListClassNameContract, DesignSystem> = {
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<DashboardListProps> = React.memo(
({ listData, managedClasses, onDeleteSelected, frozen }) => {
const masonryRef = useRef(null);
const onResizeRef = useRef(null);
const [selectedItems, setSelectedItems] = useState<string[]>([]);
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 <DashboardListCell/>
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 (
<div className={managedClasses.dashboardList}>
<DashboardListToolbar
visible={inSelectMode}
selectedAmount={selectedItems.length}
onDelete={onDelete}
/>
{listData !== null && listData.length > 0 && (
<WindowScroller>
{({ height, scrollTop }) => (
<AutoSizer disableHeight height={height} onResize={debouncedOnResize()}>
{() => (
<Masonry
className={inSelectMode ? "selecting" : ""}
ref={masonryRef}
cellCount={listData.length}
cellMeasurerCache={cache}
cellPositioner={cellPositioner}
cellRenderer={props => {
const data = listData[props.index];
if (typeof data === "undefined") return null;
return (
<DashboardListCell
{...props}
cache={cache}
data={data}
onSelect={selectItem(data.id)}
selected={selectedItems.findIndex(i => i === data.id) > -1}
selectMode={inSelectMode}
/>
);
}}
autoHeight={true}
height={height}
scrollTop={scrollTop}
width={masonryBounds.width}
overscanByPixels={100}
style={{ left: masonryBounds.left }}
/>
)}
</AutoSizer>
)}
</WindowScroller>
)}
</div>
);
}
);
export default manageJss(styles)(DashboardList);

View File

@ -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<DashboardListCellClassNameContract>,
MasonryCellProps {
cache: CellMeasurerCache;
data: ListDataItem;
selected: boolean;
selectMode: boolean;
onSelect: (selected: boolean) => void;
}

View File

@ -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<DashboardListCellClassNameContract, DesignSystem> = {
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<CheckboxClassNameContract, DesignSystem> = {
checkbox_input: {
borderRadius: "50%",
},
checkbox_stateIndicator: {},
checkbox__checked: {
"& $checkbox_stateIndicator": {
"&::before": {
backgroundSize: "17px",
backgroundPosition: "1px 2px",
},
},
},
};
const onImageLoaded: React.ReactEventHandler<HTMLImageElement> = ({ currentTarget }) => {
currentTarget.style.opacity = "1";
currentTarget.style.pointerEvents = "none";
};
const onImageError: React.ReactEventHandler<HTMLImageElement> = ({ currentTarget }) => {
currentTarget.style.display = "none";
currentTarget.style.pointerEvents = "none";
};
/**
* Renders a cell and sets its height in the CellMeasurementCache
*/
const CellRenderer: React.FC<DashboardListCellProps> = props => {
let { id, title, thumb_height } = props.data;
const { t } = useTranslation("dashboard");
const cellRef = useRef<HTMLDivElement>(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 <CellMeasurer />
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<HTMLInputElement> & 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<HTMLAnchorElement> = e => {
props.selectMode && e.preventDefault();
};
return (
<motion.div
key={props.key}
layoutId={`card-image-container-${id}`}
className={classNames(props.managedClasses.dashboardListCell, [
props.managedClasses.dashboardListCell__checked,
props.selected,
])}
onAnimationStart={onAnimationStart}
onAnimationComplete={onAnimationEnd}
onClick={onCheckmarkChange}
ref={cellRef}
style={{
...props.style,
height: thumb_height,
}}
>
<Link to={`/${id}/`} onClick={shouldExecuteclick}>
<img
className={props.managedClasses.dashboardListCell_image}
src={`${window.location.origin}/${id}.thumb.jpg`}
alt={title}
onError={onImageError}
onLoad={onImageLoaded}
/>
</Link>
<Checkbox
inputId={id}
className={props.managedClasses.dashboardListCell_checkbox}
onChange={onCheckmarkChange as any}
jssStyleSheet={checkboxStyle}
checked={props.selected}
/>
<div className={props.managedClasses.dashboardListCell_overlay} />
<footer
className={props.managedClasses.dashboardListCell_metadata}
tabIndex={0}
// Make sure that small images are still selectable
style={thumb_height < 45 ? { display: "none" } : {}}
>
<Label tabIndex={-1}>{title}</Label>
</footer>
</motion.div>
);
};
export default manageJss(styles)(CellRenderer);

View File

@ -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<DashboardListToolbarClassNameContract> {
visible?: boolean;
selectedAmount?: number;
onDelete: () => void;
}

View File

@ -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<DashboardListToolbarClassNameContract, DesignSystem> = {
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<DashboardListToolbarProps> = props => {
const { t } = useTranslation("dashboard");
return (
<div
className={classNames(props.managedClasses.dashboardListToolbar, [
props.managedClasses.dashboardListToolbar__visible,
props.visible,
])}
>
<Button
icon={FaTrash}
appearance={ButtonAppearance.justified}
onClick={props.onDelete}
>
{t("deleteSelected", { count: props.selectedAmount })}
</Button>
</div>
);
};
export default manageJss(styles)(DahboardListToolbar);

17
src/FileView/FileView.props.ts Executable file
View File

@ -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<FileViewClassNameContract> {
fileData?: Window["fileData"];
}

149
src/FileView/FileView.tsx Executable file
View File

@ -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<FVSidebarProps> = FullscreenLoader(
import(/* webpackChunkName: "FVSidebar" */ "./views/FVSidebar/FVSidebar")
);
const styles: ComponentStyles<FileViewClassNameContract, DesignSystem> = {
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<HeaderClassNameContract, DesignSystem> = {
header: {
zIndex: "40",
},
header_left: {},
};
let largeImage: HTMLImageElement = null;
const FileView: React.FC<FileViewProps> = ({
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<React.MouseEventHandler>(() => {});
/**
* Used to decide whether to view the thumbnail or the original image
*/
const [largeImageLoaded, setLargeImageLoadedState] = useState(false);
/**
* Image manipulation involves overflowing the body
* Moreover, <DashboardList/> 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 <ImageViewer/>
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 (
<FVSidebarProvider fileData={fileData}>
<div className={managedClasses.fileView}>
<motion.div
className={managedClasses.fileViewBackground}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
exit={{ opacity: 0 }}
onAnimationComplete={loadLargeImage}
>
<Header
position="absolute"
jssStyleSheet={headerStyles}
centerContent={<HeaderCenterContent />}
rightSideContent={<HeaderRightContent onMagnify={onMagnify.current} />}
/>
<FVSidebarToggleButton />
</motion.div>
<ImageViewer imageURL={imageURL} fileData={fileData} zoomRef={setZoomRef} />
{largeImageLoaded && <FVSidebar fileData={fileData} />}
</div>
</FVSidebarProvider>
);
};
export default manageJss(styles)(FileView);

View File

@ -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<FVSidebarClassNameContract> {
fileData: Window["fileData"];
}

View File

@ -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<FVSidebarClassNameContract, DesignSystem> = {
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<FVSidebarProps> = ({ 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 (
<>
<motion.div
className={managedClasses.fv_sidebar}
style={{
x: sidebarContainerX,
}}
>
<Heading size={HeadingSize._5} tag={HeadingTag.h1}>
{t("inspector")}
</Heading>
<div className={managedClasses.fv_sidebar_container}>
<FVSidebarContent fileData={fileData} />
<FVSidebarFooter fileData={fileData} />
</div>
</motion.div>
</>
);
};
export default manageJss(styles)(FVSidebar);

View File

@ -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<FVSidebarContentClassNameContract> {
fileData: Window["fileData"];
}

View File

@ -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<FVSidebarContentClassNameContract, DesignSystem> = {
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<FVSidebarContentProps> = memo(
({ managedClasses, fileData }) => {
const { t } = useTranslation("fileview");
const uploaded = new Date(fileData.timestamp * 1000);
const title = fileData.title === "untitled" ? "-" : fileData.title;
return (
<div className={managedClasses.fv_sidebarContent}>
<dl className={managedClasses.fv_sidebarContent_list}>
<dt>{t("description")}:</dt>
<dd>{isLoggedIn ? <FVSidebarDescEditor fileData={fileData} /> : title}</dd>
<dt>{t("width")}:</dt>
<dd>{fileData.width || "-"}</dd>
<dt>{t("height")}:</dt>
<dd>{fileData.height || "-"}</dd>
<dt>{t("format")}:</dt>
<dd>{fileData.extension || "-"}</dd>
<dt>{t("uploaded")}:</dt>
<dd>
{uploaded
? `${uploaded.toLocaleDateString()} ${uploaded.toLocaleTimeString()}`
: "-"}
</dd>
</dl>
</div>
);
}
);
export default manageJss(styles)(FVSidebarContent);

View File

@ -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<number>;
/**
* 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<number>;
/**
* True, already while it's opening.
* False, already while closing.
*/
isSidebarVisible: boolean;
setSidebarVisibility: React.Dispatch<React.SetStateAction<boolean>>;
isSidebarFloating: boolean;
fileTitle: string;
setFileTitle: React.Dispatch<React.SetStateAction<string>>;
}

View File

@ -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<ISidebarData>(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 <SidebarData.Provider children={children} value={sidebarValues} />;
};
export default FVSidebarProvider;

View File

@ -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<FVSidebarFooterProps> = ({
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 (
<Button appearance={ButtonAppearance.stealth} icon={FaTrash} onClick={onDelete}>
{t("delete")}
</Button>
);
};
export default FVSidebarDeleteButton;

View File

@ -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<FVSidebarDescEditorClassNameContract> {
fileData: Window["fileData"];
}

View File

@ -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<FVSidebarDescEditorClassNameContract, DesignSystem> = {
"@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<ProgressClassNameContract, DesignSystem> = {
progress_circularSVG__container: {
width: "auto",
height: "auto",
},
progress_circularSVG__control: {},
progress_circularSVG__page: {},
};
const FVSidebarDescEditor: React.ComponentType<FVSidebarDescEditorProps> = ({
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<HTMLInputElement> = async ({ currentTarget }) => {
let { value } = currentTarget;
value = value.trim();
// Avoid saving the same
if (value === fileTitle) return;
// If the request takes too long, <Progress/> 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 (
<TextAction
className={managedClasses.fv_sidebar_descEditor}
name="description"
placeholder={"-"}
defaultValue={!fileTitle || fileTitle === "untitled" ? "" : fileTitle}
onBlur={onBlur}
onFocus={onFocus}
maxLength={255}
afterGlyph={name =>
loadingState === "loading" ? (
<Progress
className={name}
circular={true}
minValue={0}
maxValue={100}
value={0}
jssStyleSheet={progressStyles}
/>
) : loadingState === "success" ? (
<FaCheck className={name + " " + managedClasses.fv_sidebar_descEditor_check} />
) : (
loadingState === "error" && <FaExclamationTriangle className={name} />
)
}
/>
);
};
export default manageJss(styles)(FVSidebarDescEditor);

View File

@ -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<FVSidebarFooterClassNameContract> {
fileData: Window["fileData"];
}

View File

@ -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<FVSidebarFooterClassNameContract, DesignSystem> = {
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<FVSidebarFooterProps> = ({
managedClasses,
fileData,
}) => {
const { t } = useTranslation(["fileview", "common"]);
const desCtx = useContext(designSystemContext) as DesignSystem;
return (
<footer
className={managedClasses.fv_sidebarFooter}
style={{
// We do this, so the component updates on theme change
background: neutralLayerL2(desCtx),
}}
>
<div className={managedClasses.fv_sidebarFooter_links}>
<Hypertext href={window.location.origin}>
{t("common:hyperlinks.about")}
</Hypertext>
<Hypertext href={window.location.origin}>
{t("common:hyperlinks.thirdParty")}
</Hypertext>
<Hypertext href={window.location.origin}>
{t("common:hyperlinks.changeLanguage")}
</Hypertext>
</div>
<div className={managedClasses.fv_sidebarFooter_buttons}>
{isLoggedIn && <FVSidebarDeleteButton fileData={fileData} />}
<Button
appearance={ButtonAppearance.stealth}
icon={FaDownload}
href={`${window.location.origin}/${fileData.id}.${fileData.extension}`}
target="_blank"
download
>
{t("download")}
</Button>
<Button
appearance={ButtonAppearance.stealth}
icon={FaExternalLinkSquareAlt}
href={`${window.location.origin}/${fileData.id}.${fileData.extension}`}
target="_blank"
>
{t("source")}
</Button>
</div>
</footer>
);
};
export default manageJss(styles)(FVSidebarFooter);

View File

@ -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<FVSidebarToggleButtonClassNameContract> {}

View File

@ -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<FVSidebarToggleButtonClassNameContract, DesignSystem> = {
fv_sidebarButton: {
zIndex: "63",
position: "absolute",
right: defaultButtonPos + "px",
top: "15px",
...applyPillCornerRadius(),
},
};
/**
* Custom styling for sidebar toggle.
*/
const customCaretStyle: ComponentStyles<ButtonClassNameContract, DesignSystem> = {
button: {
paddingTop: "6px",
paddingLeft: "6px",
margin: "0",
"& > svg": {
paddingBottom: "2px",
},
"& > div": {
height: "17px",
},
},
};
const FVSidebarToggleButton: React.ComponentType<FVSidebarToggleButtonProps> = ({
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 (
<motion.div
className={managedClasses.fv_sidebarButton}
style={{
x: buttonPosition,
background: buttonBackground,
}}
onHoverStart={() => setButtonHover(true)}
onHoverEnd={() => setButtonHover(false)}
>
<Button
beforeContent={classname => (
<>
<motion.div
animate={{
/**
* Hover animation.
*
* We also move the caret by a few pixels
* when the sidebar is opened
* to ensure optical centering
*/
x: isButtonHover ? (isSidebarVisible ? 5 : -3) : isSidebarVisible ? 2 : 0,
}}
style={{ rotate: buttonIconRotation }}
>
<FaCaretLeft className={classname} />
</motion.div>
<FaInfo className={classname} />
</>
)}
onClick={() => setSidebarVisibility(!isSidebarVisible)}
jssStyleSheet={customCaretStyle}
/>
</motion.div>
);
};
export default manageJss(styles)(FVSidebarToggleButton);

View File

@ -0,0 +1,23 @@
import React, { useContext } from "react";
import { Paragraph } from "@microsoft/fast-components-react-msft";
import { SidebarData } from "../FVSidebar/FVSidebarContext";
import { useTransform, motion } from "framer-motion";
export const HeaderCenterContent: React.ComponentType<{}> = () => {
const { sidebarPos, fileTitle } = useContext(SidebarData);
const headerCenterPos = useTransform(sidebarPos, pos => pos * 0.3 * -1);
const headerCenterOpacity = useTransform(sidebarPos, pos =>
Math.max(0, pos * (-1 / 300) + 1)
);
return !fileTitle || fileTitle === "untitled" ? null : (
<motion.div
style={{
x: headerCenterPos,
opacity: headerCenterOpacity,
}}
>
<Paragraph size={2}>{fileTitle}</Paragraph>
</motion.div>
);
};

View File

@ -0,0 +1,15 @@
import { ManagedClasses } from "@microsoft/fast-jss-manager-react";
/**
* Class name contract for the component
*/
export interface HeaderRightContentClassNameContract {}
/**
* Props for the component
*/
export interface HeaderRightContentProps
extends ManagedClasses<HeaderRightContentClassNameContract> {
onMagnify?: React.MouseEventHandler;
onShare?: React.MouseEventHandler;
}

View File

@ -0,0 +1,75 @@
import { ButtonAppearance } from "@microsoft/fast-components-react-msft";
import { DesignSystem, baseLayerLuminance } from "@microsoft/fast-components-styles-msft";
import { ComponentStyles } from "@microsoft/fast-jss-manager-react";
import React, { useContext } from "react";
import { FaAdjust, FaSearchPlus, FaShareAlt } from "react-icons/fa";
import { Button, DesignToolkit, useMotionValueFactory } from "../../../_DesignSystem";
import { ButtonClassNameContract } from "../../../_DesignSystem/Button/Button.props";
import { HeaderRightContentProps } from "./HeaderRightContent.props";
import { defaultButtonPos } from "../FVSidebar/FVSidebarToggleButton";
import { motion } from "framer-motion";
import { SidebarData } from "../FVSidebar/FVSidebarContext";
/**
* Rotate the theme switcher icon based on the current theme
*/
const customThemeSwitcherStyle: ComponentStyles<ButtonClassNameContract, DesignSystem> = {
button: {
transform: des => {
const luminance: number = baseLayerLuminance(des);
return `rotate(${luminance > 0.5 ? 180 : 0}deg)`;
},
transition: "transform .3s",
},
};
/**
* We need to make space for the sidebar button.
*/
const marginLeftStyleSheet: ComponentStyles<ButtonClassNameContract, DesignSystem> = {
button: {
marginRight: "60px",
},
};
export const HeaderRightContent: React.ComponentType<HeaderRightContentProps> = ({
onMagnify,
onShare,
}) => {
const themeCtx = useContext(DesignToolkit);
const { sidebarPos, isSidebarFloating } = useContext(SidebarData);
/**
* Move right-side header contents alongside sidebar
* if the window is big enough
*/
const headerRightPosX = useMotionValueFactory(
() =>
isSidebarFloating
? 0
: Math.min((sidebarPos.get() - (defaultButtonPos + 63 - 12 - 5)) * -1, 0),
[sidebarPos, isSidebarFloating]
);
return (
<motion.div style={{ x: headerRightPosX }}>
<Button
appearance={ButtonAppearance.lightweight}
icon={FaSearchPlus}
onClick={onMagnify}
/>
<Button
appearance={ButtonAppearance.lightweight}
icon={FaAdjust}
onClick={themeCtx.toggleTheme}
jssStyleSheet={customThemeSwitcherStyle}
/>
<Button
appearance={ButtonAppearance.lightweight}
icon={FaShareAlt}
onClick={onShare}
jssStyleSheet={marginLeftStyleSheet}
/>
</motion.div>
);
};

View File

@ -0,0 +1,20 @@
import { ManagedClasses } from "@microsoft/fast-jss-manager-react";
/**
* Class name contract for the component
*/
export interface ImageViewerClassNameContract {
imageViewer: string;
imageViewer__zoomedin?: string;
imageViewer__dragging?: string;
imageViewer_imageContainer: string;
}
/**
* Props for the component
*/
export interface ImageViewerProps extends ManagedClasses<ImageViewerClassNameContract> {
imageURL: string;
fileData: Window["fileData"];
zoomRef?: (ref: React.MouseEventHandler) => void;
}

View File

@ -0,0 +1,432 @@
/* eslint-disable jsx-a11y/alt-text */
import React, {
useRef,
useState,
useLayoutEffect,
useEffect,
useMemo,
useContext,
} from "react";
import { ImageViewerProps, ImageViewerClassNameContract } from "./ImageViewer.props";
import { DesignSystem } from "@microsoft/fast-components-styles-msft";
import manageJss, { ComponentStyles, CSSRules } from "@microsoft/fast-jss-manager-react";
import {
motion,
useMotionValue,
TapHandlers,
MotionValue,
useDomEvent,
} from "framer-motion";
import { headerHeight } from "../../../_DesignSystem";
import { useViewportDimensions } from "./useViewportDimensions";
import { classNames } from "@microsoft/fast-web-utilities";
import ImageViewerSlider from "./ImageViewerSlider";
import { TweenProps, spring } from "popmotion";
import { SidebarData } from "../FVSidebar/FVSidebarContext";
import { debounce } from "lodash-es";
const applyCenteredAbsolute: CSSRules<DesignSystem> = {
position: "absolute",
userSelect: "none",
top: headerHeight + "px",
left: "0",
right: "0",
bottom: "0",
margin: "auto",
"& img": {
width: "100%",
height: "100%",
},
};
const styles: ComponentStyles<ImageViewerClassNameContract, DesignSystem> = {
imageViewer: {
flexGrow: "1",
position: "relative",
"& > iframe": {
opacity: 0,
pointerEvents: "none",
},
},
imageViewer_imageContainer: {
...applyCenteredAbsolute,
cursor: "zoom-in",
},
imageViewer__zoomedin: {
cursor: "grab",
},
imageViewer__dragging: {
cursor: "grabbing",
},
};
// Used to determine whether user clicked or panned
let lastDragPoint = { x: 0, y: 0 };
/**
* Animation function to be used with `MotionValue.start()`.
* It uses popmotion's inbuilt spring function.
*
* @param motionValue The `MotionValue` you attach this function to.
* @param config A custom config for popmotion's tween function.
*/
const animateWithSpring = (motionValue: MotionValue, config: TweenProps) => (
complete: () => void
) => {
const animation = spring({
to: 0,
...config,
velocity: motionValue.getVelocity(),
from: motionValue.get(),
stiffness: 400,
damping: 60,
}).start({
complete,
update: (val: number) => motionValue.set(val),
});
return animation.stop;
};
/**
* The main ImageViewer Component
*/
const ImageViewer: React.ComponentType<ImageViewerProps> = ({
imageURL,
managedClasses,
fileData,
zoomRef,
}) => {
fileData = fileData || window.fileData;
const { id, title, width, height } = fileData;
/**
* We pre-render the image server-side,
* so that users who are unable to enjoy the full experience
* can also at least see the actual image.
*
* This will remove that pre-rendered image, since the web-app
* has already rendered it on its own.
*/
const onImageLoaded = () => {
const container = document.getElementById("preContainer");
if (container) container.remove();
};
/**
* React.Ref from the draggable component
*/
const draggableRef = useRef<HTMLDivElement>(null);
// Viewport dimensions
const [resizeListener, { viewportWidth, viewportHeight }] = useViewportDimensions();
/**
* Used for detecting the current state of the sidebar
*/
const { sidebarPos, isSidebarFloating } = useContext(SidebarData);
/**
* Debounce sidebar position changes, so that we can avoid
* expensive calculations and renderings
*/
const [debouncedSidebarPos, setSidebarPos] = useState(sidebarPos.get());
useEffect(() => {
const listener = () => setSidebarPos(isSidebarFloating ? 0 : sidebarPos.get());
listener();
return sidebarPos.onChange(debounce(listener, 20));
}, [isSidebarFloating, sidebarPos]);
/**
* Minimum scale factor.
*
* It can be used to determine the default size
* of the image, hence the name.
*/
const defaultScale = useMemo(() => {
let newScaleFactor = 1;
const aspectRatio = width / height;
const adjustedViewportWidth = viewportWidth - debouncedSidebarPos;
if (width - adjustedViewportWidth > 0 || height - viewportHeight > 0) {
if (viewportHeight * aspectRatio <= adjustedViewportWidth) {
const adaptedWidth = width * (viewportHeight / height);
newScaleFactor = adaptedWidth / width;
} else {
const adaptedHeight = height * (adjustedViewportWidth / width);
newScaleFactor = adaptedHeight / height;
}
}
return newScaleFactor;
}, [debouncedSidebarPos, height, viewportHeight, viewportWidth, width]);
/**
* Current scale factor
*
* This number can be manipulated by the user.
*/
const [scale, setScaleState] = useState(defaultScale);
/**
* If activated, <ImageViewerSlider/> appears and the image
* is draggable
*/
const [inTransformMode, setTransformState] = useState(false);
/**
* Resizes the image based on the wheel direction and
* toggles transform mode if needed.
*/
const onDraggableWheel = (e: WheelEvent) => {
const newScaleFactor = Math.min(
Math.max(scale + (e.deltaY / 150) * -1, defaultScale),
3
);
setScaleState(newScaleFactor);
if (
(newScaleFactor > defaultScale && !inTransformMode) ||
(newScaleFactor <= defaultScale && inTransformMode)
) {
setTransformState(!inTransformMode);
}
};
// Assign listener to wheel event
useDomEvent(draggableRef, "wheel", onDraggableWheel as EventListener, {
passive: false,
});
/**
* Caches the lastDragPoint for onImageTap
*/
const onTapStart: TapHandlers["onTapStart"] = (_e, { point }) => {
lastDragPoint = point;
};
/**
* Toggles the transform mode based on whether
* the user dragged the image.
*/
const onTap: TapHandlers["onTap"] = (_e, { point }) => {
if (point.x === lastDragPoint.x && point.y === lastDragPoint.y)
setTransformState(!inTransformMode);
};
// Dragging state
const [isDragging, setDraggingState] = useState(false);
// Toggle the dragging state
const onDragStart = () => !isDragging && setDraggingState(true);
const onDragEnd = () => isDragging && setDraggingState(false);
/**
* Position of the image in the x-axis.
*
* Keep in mind that the origin is on the
* centre of the page.
*/
const posX = useMotionValue(0);
/**
* Position of the image in the y-axis.
*
* Keep in mind that the origin is on the
* centre of the page.
*/
const posY = useMotionValue(0);
/**
* Calling ref function prop to pass on the handleToggle
* function as a way to externally toggle the transform mode
*/
useLayoutEffect(() => {
zoomRef(() => setTransformState(!inTransformMode));
return () => zoomRef(() => {});
}, [inTransformMode, zoomRef]);
/**
* To keep the image centered if it is bigger than the viewport
* we adjust the X axis by the delta of the overflowing part
*/
const posXAdjusted = useMotionValue<number>(0);
/**
* The amount of overflow of the image in the x axis that
* needs to be moved back in order to keep the image centered.
*/
const deltaX: number = useMemo(() => Math.min(0, (viewportWidth - width) / 2), [
viewportWidth,
width,
]);
/**
* Listen to `deltaX`, `posX` and `sidebarPos` changes and adjust posXAdjusted
*/
useLayoutEffect(() => {
const posXAdjustor = () => {
posXAdjusted.set(
posX.get() + deltaX - (isSidebarFloating ? 0 : sidebarPos.get()) / 2
);
};
posXAdjustor();
const cleanup1 = posX.onChange(posXAdjustor);
const cleanup2 = sidebarPos.onChange(posXAdjustor);
return () => {
cleanup1();
cleanup2();
};
}, [deltaX, isSidebarFloating, posX, posXAdjusted, sidebarPos]);
/**
* Calculated constraints that only allow to move the image
* if it is bigger than the viewport
*/
const dragConstraints = useMemo(() => {
const overflowX = Math.max(width * scale - (viewportWidth - debouncedSidebarPos), 0);
const overflowY = Math.max(height * scale - viewportHeight, 0);
return {
top: -1 * (overflowY / 2),
bottom: overflowY / 2,
left: -1 * (overflowX / 2),
right: overflowX / 2,
};
}, [debouncedSidebarPos, height, scale, viewportHeight, viewportWidth, width]);
/**
* Enlargens the image if entering transform mode,
* resets image size if leaving transform mode.
*
* The effect below handles further cleanup tasks.
*/
useLayoutEffect(() => {
if (inTransformMode) setScaleState(defaultScale < 1 ? 1 : 1.2);
else setScaleState(defaultScale);
}, [defaultScale, inTransformMode, posX, posY]);
/**
* Move image back to co constraints if it is overflowing.
* This can happen while scaling down the image.
*/
useEffect(() => {
const { top, bottom, left, right } = dragConstraints;
const y = posY.get();
const x = posX.get();
let newY = y;
let newX = x;
if (y < top) newY = top;
if (y > bottom) newY = bottom;
if (x < left) newX = left;
if (x > right) newX = right;
if (newY !== y) {
posY.stop();
posY.start(animateWithSpring(posY, { to: newY }));
}
if (newX !== x) {
posX.stop();
posX.start(animateWithSpring(posX, { to: newX }));
}
}, [dragConstraints, posX, posY]);
/**
* Used for determining whether a magic animation
* is running.
*/
type isMagicAnimRunning = boolean;
const [isMagicAnimRunning, setMagicAnimState] = useState(false);
// Toggle `isMagicAnimRunning`
const onMagicAnimStart = () => !isMagicAnimRunning && setMagicAnimState(true);
const onMagicAnimEnd = () => isMagicAnimRunning && setMagicAnimState(false);
/**
* Attributes for all img-Tags in this component
*/
const sharedImgAttributes = {
src: imageURL,
alt: title,
draggable: false,
};
// Default dimensions used for magic component
const defaultWidth = useMemo(() => width * defaultScale, [defaultScale, width]);
const defaultHeight = useMemo(() => height * defaultScale, [defaultScale, height]);
return (
<motion.div className={managedClasses.imageViewer}>
{resizeListener}
<motion.div
/**
* This component is used for magic animations
* connecting the cards in the dashboard
* with this ImageViewer.
*
* For the sake of simplicity, we use a seperate
* component for magic animations and only make it
* visible if needed.
*/
layoutId={`card-image-container-${id}`}
className={managedClasses.imageViewer_imageContainer}
onAnimationStart={onMagicAnimStart}
onAnimationComplete={onMagicAnimEnd}
tabIndex={-1}
style={{
visibility: isMagicAnimRunning ? "visible" : "hidden",
width: defaultWidth,
height: defaultHeight,
}}
>
<img {...sharedImgAttributes} tabIndex={-1} />
</motion.div>
<motion.div
className={classNames(
managedClasses.imageViewer_imageContainer,
[managedClasses.imageViewer__zoomedin, inTransformMode],
[managedClasses.imageViewer__dragging, isDragging]
)}
ref={draggableRef}
initial={false}
animate={{ scale }}
transition={{ type: "tween", duration: 0.18 }}
draggable={false}
drag={inTransformMode}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
dragElastic={0.2}
dragConstraints={dragConstraints}
onTapStart={onTapStart}
onTap={onTap}
onLoad={onImageLoaded}
style={{
display: isMagicAnimRunning ? "none" : "block",
width: width,
height: height,
x: posXAdjusted,
y: posY,
}}
/**
* Since `posXAdjusted` is set as the value for `x`,
* we need to tell the component to modify `posX`
* so that `posXAdjusted` can be calculated
*/
_dragValueX={posX}
>
<img {...sharedImgAttributes} />
</motion.div>
<ImageViewerSlider
show={inTransformMode}
value={scale}
minFactor={defaultScale}
maxFactor={3}
onValueChange={setScaleState}
/>
</motion.div>
);
};
export default manageJss(styles)(ImageViewer);

View File

@ -0,0 +1,21 @@
import { ManagedClasses } from "@microsoft/fast-jss-manager-react";
/**
* Class name contract for the component
*/
export interface ImageViewerSliderClassNameContract {
imageViewerSlider?: string;
imageViewerSlider_label?: string;
}
/**
* Props for the component
*/
export interface ImageViewerSliderProps
extends ManagedClasses<ImageViewerSliderClassNameContract> {
show: boolean;
minFactor: number;
maxFactor: number;
value: number;
onValueChange: (value: number) => any;
}

View File

@ -0,0 +1,171 @@
import React, { useCallback, useMemo, useContext } from "react";
import { motion } from "framer-motion";
import {
ImageViewerSliderProps,
ImageViewerSliderClassNameContract,
} from "./ImageViewerSlider.props";
import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react";
import {
Slider,
SliderLabel,
SliderLabelClassNameContract,
} from "@microsoft/fast-components-react-msft";
import { applyBackdropBackground, useMotionValueFactory } from "../../../_DesignSystem";
import { parseColorHexRGBA } from "@microsoft/fast-colors";
import {
SliderTrackItemAnchor,
SliderRange,
} from "@microsoft/fast-components-react-base";
import {
DesignSystem,
applyElevation,
ElevationMultiplier,
applyPillCornerRadius,
neutralLayerFloating,
} from "@microsoft/fast-components-styles-msft";
import { SidebarData } from "../FVSidebar/FVSidebarContext";
const styles: ComponentStyles<ImageViewerSliderClassNameContract, DesignSystem> = {
imageViewerSlider: {
position: "absolute",
width: "30%",
left: "0",
right: "0",
bottom: "20px",
margin: "auto",
padding: "15px 15px 20px 15px",
...applyPillCornerRadius(),
...applyBackdropBackground(
opacity => des =>
parseColorHexRGBA(neutralLayerFloating(des) + opacity).toStringWebRGBA(),
"ba"
),
...applyElevation(ElevationMultiplier.e10),
},
imageViewerSlider_label: {
display: "inline-grid",
pointerEvents: "none",
},
};
const labelStyle: ComponentStyles<SliderLabelClassNameContract, DesignSystem> = {
sliderLabel: {
cursor: "pointer",
},
};
/**
* Defines the point where the image
* is in its original size.
*/
const ogPos = 100;
/**
* A floating slider with procentual labels.
* Used in conjunction with ImageViewer.
*/
const ImageViewerSlider: React.ComponentType<ImageViewerSliderProps> = ({
managedClasses,
show,
value,
minFactor,
maxFactor,
onValueChange,
}) => {
const { sidebarPos, isSidebarFloating } = useContext(SidebarData);
/**
* Current position on the slider.
*/
type value = number;
value = value * 100;
/**
* Lowest point on the slider.
*/
const minValue = useMemo(() => minFactor * 100, [minFactor]);
/**
* Highest point on the slider.
*/
const maxValue = useMemo(() => maxFactor * 100, [maxFactor]);
/**
* Tells whether the image needed to be
* shrunk in order to fit the viewport.
*/
const isLargeImage = useMemo(() => minValue < 100, [minValue]);
/**
* Position of the fixed label that hints
* either at 1x or 2x depending on the image size.
*/
const fixedLabelPos = useMemo(() => (isLargeImage ? ogPos : maxValue - 100), [
isLargeImage,
maxValue,
]);
/**
* Decides whether showing the label would be
* intrusive or unfitting.
*/
const shouldShowLabel =
value > minValue + 5 &&
value < maxValue - 5 &&
(value < ogPos - 20 || value > ogPos + 20);
/**
* Formats the new value and passes it on to the callback prop.
* @param v THe new value of the slider
*/
const handleValueChange = useCallback(
(v: number | SliderRange) =>
(v as number).toFixed && onValueChange((v as number) / 100),
[onValueChange]
);
/**
* Moves the slider if sidebar is opened.
*/
const sliderPosX = useMotionValueFactory(
() => (isSidebarFloating ? 0 : -1 * (sidebarPos.get() / 2)),
[sidebarPos, isSidebarFloating]
);
return (
<motion.div
className={managedClasses.imageViewerSlider}
initial={{ y: "200%" }}
animate={{ y: show ? 0 : "200%" }}
style={{ x: sliderPosX }}
>
<Slider
range={{ minValue, maxValue }}
value={value}
onValueChange={handleValueChange}
>
<SliderLabel
label={isLargeImage ? "100%" : "200%"}
valuePositionBinding={fixedLabelPos}
showTickmark={true}
onClick={() => handleValueChange(fixedLabelPos)}
jssStyleSheet={labelStyle}
/>
<motion.div
className={managedClasses.imageViewerSlider_label}
initial={{ opacity: 1 }}
animate={{ opacity: shouldShowLabel ? 1 : 0 }}
transition={{ default: 0.1 }}
>
<SliderLabel
label={Math.floor((value * (isLargeImage ? 100 : 200)) / ogPos) + "%"}
valuePositionBinding={SliderTrackItemAnchor.selectedRangeMax}
showTickmark={true}
/>
</motion.div>
</Slider>
</motion.div>
);
};
export default manageJss(styles)(ImageViewerSlider);

View File

@ -0,0 +1,27 @@
import { headerHeight } from "../../../_DesignSystem";
import useResizeAware from "react-resize-aware";
/**
* The dimensions of the parent element
* or the dimensions of the window.
*/
export interface ViewportDimensions {
viewportWidth: number;
viewportHeight: number;
}
/**
* Returns the width and height of the enclosing element,
* as well as a React node you need to place inside the measured
* element in order to handle resize events.
*/
export const useViewportDimensions = (): [React.ReactNode, ViewportDimensions] => {
const reportDimensions = (target: HTMLObjectElement): ViewportDimensions => ({
viewportWidth: target !== null ? target.clientWidth : window.innerWidth,
viewportHeight:
(target !== null ? target.clientHeight : window.innerHeight) - headerHeight,
});
const [resizeListener, dimensions] = useResizeAware(reportDimensions);
return [resizeListener, dimensions];
};

View File

@ -0,0 +1,22 @@
import { ManagedClasses } from "@microsoft/fast-jss-manager-react";
/**
* Class name contract for the component
*/
export interface FullscreenDropzoneClassNameContract {
fullscreenDropzone?: string;
fullscreenDropzone_visisble?: string;
fullscreenDropzone_dragging?: string;
fullscreenDropzoneDialogContent_header?: string;
fullscreenDropzoneDialogContent_buttons?: string;
}
/**
* Props for the component
*/
export interface FullscreenDropzoneProps
extends ManagedClasses<FullscreenDropzoneClassNameContract> {}
export interface WrappedDropzoneComponentProps {
openDropzoneDialog: () => void;
}

View File

@ -0,0 +1,225 @@
/* eslint-disable react-hooks/rules-of-hooks */
import React, { Fragment, useState, useEffect, useCallback } from "react";
import { DesignSystem } from "@microsoft/fast-components-styles-msft";
import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react";
import {
FullscreenDropzoneClassNameContract,
FullscreenDropzoneProps,
} from "./FullscreenDropzone.props";
import { classNames } from "@microsoft/fast-web-utilities";
import { useDropzone, DropEvent, FileRejection } from "react-dropzone";
import DropzoneDrag from "./views/DropzoneDrag";
import {
Dialog,
DialogClassNameContract,
Typography,
TypographyTag,
TypographySize,
ButtonAppearance,
} from "@microsoft/fast-components-react-msft";
import { Button } from "../_DesignSystem";
import DropzoneUploadManager from "./views/DropzoneUploadManager";
import { useTranslation } from "react-i18next";
import { FaTimes } from "react-icons/fa";
const FullscreenDropzoneStyles: ComponentStyles<
FullscreenDropzoneClassNameContract,
DesignSystem
> = {
fullscreenDropzone: {
display: "none",
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
background: "rgba(0,0,0,0.3)",
zIndex: "3",
paddingTop: "0",
},
// The dialog can also be opened with a button
fullscreenDropzone_visisble: {
display: "block",
},
// While dragging, only fullscreenDropzone should be accessable
fullscreenDropzone_dragging: {
pointerEvents: "none",
},
fullscreenDropzoneDialogContent_header: {
display: "flex",
justifyContent: "space-between",
outline: "none",
marginBottom: "20px",
},
fullscreenDropzoneDialogContent_buttons: {
display: "flex",
alignItems: "center",
"& > *:first-child": {
marginRight: "10px",
},
},
};
const DialogStyles: ComponentStyles<DialogClassNameContract, DesignSystem> = {
dialog_contentRegion: {
position: "relative",
display: "flex",
flexDirection: "column",
padding: "21px 24px",
borderRadius: "10px",
width: "50vw !important",
height: "auto !important",
maxHeight: "70vh",
minHeight: "480px",
"& > h1": {
cursor: "default",
},
},
"@media (max-width: 920px)": {
dialog_contentRegion: {
width: "80vw !important",
},
},
};
/**
* Component for setting up a fullscreen dropzone.
* It consists of the dragover-logic and a modal that shows the upload progress afterwards.
*/
const FullscreenDropzone = (
WrappedComponent: React.ComponentClass | React.FunctionComponent
): React.FC<FullscreenDropzoneProps> => {
return props => {
const [isUploadDialogVisible, setDialogVisibility] = useState(false);
const [isDragActive, setDragState] = useState(false);
const [preventDialogHiding, setDialogHidingPrevention] = useState(false);
const [dropData, setDropData] = useState({ acceptedFiles: [], rejectedFiles: [] });
const { t } = useTranslation("dashboard");
let lastDropTarget: EventTarget = null;
/**
* Callback function for showing the upload modal if a draggable appears in the viewport.
*
* @param {DragEvent} e The provided DragEvent object.
*/
const onDocumentDragOver = (e: DragEvent) => {
const { dataTransfer, target } = e;
if (dataTransfer.types && dataTransfer.types.indexOf("Files") !== -1) {
lastDropTarget = target;
if (!isUploadDialogVisible) setDialogVisibility(true);
if (!isDragActive) setDragState(true);
}
};
/**
* Callback function for hiding the upload modal if nothing is dragged into anymore.
*
* We additionally check, whether we lost contact with ```lastDropTarget```,
* which should always be ```.fullscreenDropzone```.
* This way, we make sure that the dragged item is still in the viewport.
*
* @param {DragEvent} e The provided DragEvent object.
*/
const onDocumentDragLeave = (e: DragEvent) => {
if (e.target === lastDropTarget || e.target === document) {
if (isDragActive) setDragState(false);
if (!preventDialogHiding) setDialogVisibility(false);
}
};
/**
* We send the data down to a separate component which handles all uploads.
*/
const onDrop: <T extends File>(
acceptedFiles: T[],
fileRejections: FileRejection[],
event: DropEvent
) => void = useCallback(
(acceptedFiles, rejectedFiles) => {
if (!preventDialogHiding) setDialogHidingPrevention(true);
setDragState(false);
setDropData({ acceptedFiles, rejectedFiles });
},
[preventDialogHiding]
);
const close = () => {
setDialogHidingPrevention(false);
setDragState(false);
setDialogVisibility(false);
};
/**
* Setting up and removing event listeners on mounting and demounting
*/
useEffect(() => {
window.addEventListener("dragover", onDocumentDragOver);
window.addEventListener("dragleave", onDocumentDragLeave);
return () => {
window.removeEventListener("dragover", onDocumentDragOver);
window.removeEventListener("dragleave", onDocumentDragLeave);
};
});
const { getRootProps, getInputProps, open } = useDropzone({
onDrop,
noClick: true,
accept: ["image/jpeg", "image/png", "image/gif"],
});
return (
<Fragment>
<WrappedComponent {...props} />
<div
className={classNames(props.managedClasses.fullscreenDropzone, [
props.managedClasses.fullscreenDropzone_visisble,
isUploadDialogVisible,
])}
{...getRootProps()}
>
<input type="hidden" {...getInputProps()} />
<Dialog
className={classNames([
props.managedClasses.fullscreenDropzone_dragging,
isDragActive,
])}
jssStyleSheet={DialogStyles}
visible={true}
label={t("upload.aria.dialogLabel")}
modal={true}
tabIndex={-1}
>
{isDragActive && <DropzoneDrag />}
<header
tabIndex={-1}
className={props.managedClasses.fullscreenDropzoneDialogContent_header}
>
<Typography role="title" tag={TypographyTag.h1} size={TypographySize._4}>
{t("upload.title")}
</Typography>
<section
className={props.managedClasses.fullscreenDropzoneDialogContent_buttons}
>
<Button appearance={ButtonAppearance.primary} onClick={open}>
{t("upload.select")}
</Button>
<Button
icon={FaTimes}
appearance={ButtonAppearance.stealth}
onClick={close}
/>
</section>
</header>
<DropzoneUploadManager dropData={dropData} />
</Dialog>
</div>
</Fragment>
);
};
};
export const withDropzone = (
WrappedComponent: React.ComponentClass | React.FunctionComponent
) => manageJss(FullscreenDropzoneStyles)(FullscreenDropzone(WrappedComponent));

View File

@ -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<DropzoneDragClassNameContract> {}

View File

@ -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<DropzoneDragClassNameContract, DesignSystem> = {
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 (
<div className={props.managedClasses.dropzoneDrag}>
<FaImages className={props.managedClasses.dropzoneDragIcon} />
<Heading tag={HeadingTag.h1} size={2}>
{t("upload.dropTitle")}
</Heading>
<Heading tag={HeadingTag.h2} size={5}>
{t("upload.dropDescription")}
</Heading>
</div>
);
};
export default manageJss(styles)(DropzoneDrag);

View File

@ -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<DropzoneErrorClassNameContract> {
error: string;
}

View File

@ -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<DropzoneErrorClassNameContract, DesignSystem> = {
dropzoneErrorIcon: {
fontSize: "4em",
color: (designSystem: DesignSystem): string => neutralForegroundRest(designSystem),
},
};
const DropzoneError = (props: DropzoneErrorProps) => {
const { t } = useTranslation("dashboard");
return (
<React.Fragment>
<FaExclamationTriangle className={props.managedClasses.dropzoneErrorIcon} />
<Heading tag={HeadingTag.h1} size={2}>
{t("upload.error.title")}
</Heading>
<Heading tag={HeadingTag.h2} size={5}>
{props.error}
</Heading>
</React.Fragment>
);
};
export default manageJss(styles)(DropzoneError);

View File

@ -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<DropzoneUploadClassNameContract> {
key: string;
file: File;
rejected: boolean;
preview?: string;
onRemoveRequest?: () => void;
}

View File

@ -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<ProgressClassNameContract, DesignSystem> = {
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 (
<div className={props.managedClasses.dropzoneUpload}>
{!props.rejected && props.preview && (
<img
src={props.preview}
alt={props.file.name}
className={props.managedClasses.dropzoneUpload_image}
width="200"
height="200"
/>
)}
<footer className={props.managedClasses.dropzoneUpload_details} tabIndex={0}>
<Label
className={
props.rejected || errorMessage || resData.id
? props.managedClasses.dropzoneUpload_error
: ""
}
tabIndex={-1}
>
{props.rejected ? (
t("upload.error.unsupported")
) : errorMessage ? (
errorMessage
) : resData.id ? (
<Fragment>
{t("upload.finished", { filename: resData.id })}
<br />
<Hypertext href={resData.file_url} target="_">
{t("upload.visit")}
</Hypertext>
</Fragment>
) : (
props.file.name
)}
</Label>
</footer>
<div className={props.managedClasses.dropzoneUpload_progress}>
{props.rejected || errorMessage.length > 0 ? (
<ProgressIcon icon={FaExclamation} />
) : progress === 100 ? (
<ProgressIcon icon={FaCheck} />
) : (
<Progress
jssStyleSheet={ProgressStyles}
circular={true}
minValue={0}
maxValue={100}
value={progress}
/>
)}
</div>
</div>
);
};
export default manageJss(DropzoneUploadStyles)(DropzoneUpload);

View File

@ -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<DropzoneUploadManagerClassNameContract> {
dropData: {
acceptedFiles: File[];
rejectedFiles: File[];
};
}

View File

@ -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<DropzoneUploadManagerClassNameContract, DesignSystem> = {
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 (
<div className={props.managedClasses.dropzoneUploadManager}>
{uploadList.length > 0 &&
uploadList.map(
(obj: {
key: string;
file: File;
rejected: boolean;
preview: string | null;
}) => (
<DropzoneUpload
key={obj.key}
file={obj.file}
rejected={obj.rejected}
preview={obj.preview}
onRemoveRequest={removeUploadItem(obj.key)}
/>
)
)}
</div>
);
/*
return (
(dropData.acceptedFiles.length > 0 || dropData.rejectedFiles.length > 0) && (
<Fragment>
<FaExclamationTriangle className={props.managedClasses.dropzoneUploadManagerIcon} />
<Heading tag={HeadingTag.h1} size={2}>
{t("upload.error.title")}
</Heading>
<Heading tag={HeadingTag.h2} size={5}>
{errorMessage}
</Heading>
</Fragment>
)
);*/
};
export default manageJss(styles)(DropzoneUploadManager);

View File

@ -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<FullscreenLoadingIndicatorClassNameContract> {}

49
src/Loader/FullscreenLoader.tsx Executable file
View File

@ -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 (
<div className={this.props.managedClasses.fsLoaderBackdrop}>
<span>Loading...</span>
</div>
);
}
}
const StyledFullscreenLoadingIndicator = manageJss(styles)(FullscreenLoadingIndicator);
export const FullscreenLoader = <T extends unknown>(promise: Promise<any>) =>
loadable<T>(() => promise, {
fallback: <StyledFullscreenLoadingIndicator />,
});

1
src/Loader/index.ts Executable file
View File

@ -0,0 +1 @@
export * from "./FullscreenLoader";

25
src/Login/Login.props.ts Executable file
View File

@ -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<LoginClassNameContract> {}
/**
* Custom change event, since all targets are inputs
*
* @export
* @interface LoginInputChangeEvent
* @extends {React.ChangeEvent}
*/
export interface LoginInputChangeEvent extends React.ChangeEvent {
target: EventTarget & HTMLInputElement;
}

104
src/Login/Login.tsx Executable file
View File

@ -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<LoginClassNameContract, DesignSystem> = {
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<LoginProps> = 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 <form/> Element.
*
* @memberof Login
*/
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault();
const postOperation = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
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 (
<Fragment>
<div className={props.managedClasses.login}>
<Logo />
<form onSubmit={onFormSubmit} className={props.managedClasses.login_form}>
<TextAction
name="username"
placeholder={i18n.t("username")}
beforeGlyph={iconToGlyph(FaUserAlt)}
autoFocus
required
/>
<TextAction
name="password"
placeholder={i18n.t("password")}
type={TextFieldType.password}
beforeGlyph={iconToGlyph(FaKey)}
required
/>
<Button
appearance={ButtonAppearance.primary}
icon={FaSignInAlt}
disabled={isDebounced}
>
{t("loginButton")}
</Button>
</form>
</div>
</Fragment>
);
};
export default manageJss(styles)(Login);

15
src/NotFound/NotFound.props.ts Executable file
View File

@ -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<NotFoundClassNameContract> {}

60
src/NotFound/NotFound.tsx Executable file
View File

@ -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<NotFoundClassNameContract, DesignSystem> = {
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<NotFoundProps> = ({ managedClasses }) => {
const { t } = useTranslation("common");
return (
<motion.div
className={managedClasses.notFound}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<Header />
<div className={managedClasses.notFound_container}>
<FaHeartBroken className={managedClasses.notFound_icon} />
<Heading tag={HeadingTag.h1} size={2}>
{t("notFound.title")}
</Heading>
<Heading tag={HeadingTag.h2} size={5}>
{t("notFound.description")}
</Heading>
</div>
</motion.div>
);
};
export default manageJss(styles)(NotFound);

View File

@ -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<ButtonClassNameContract> {}
export interface ButtonHandledProps
extends Omit<MSFTButtonHandledProps, keyof ButtonManagedClasses>,
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;

View File

@ -0,0 +1,111 @@
/**
* Code taken from "@microsoft/fast-components-react-msft"
*
* This component is similar to the original <Button/> component from the library. Changes made:
* - An "icon" prop was added, so that it is easier to prepend an icon to the button.
* - Custom style from "ButtonStyle.ts" is applied
*/
import React from "react";
import {
ButtonProps,
ButtonClassNameContract,
ButtonHandledProps,
ButtonUnhandledProps,
} from "./Button.props";
import { ButtonAppearance } from "@microsoft/fast-components-react-msft";
import Foundation, { HandledProps } from "@microsoft/fast-components-foundation-react";
import manageJss from "@microsoft/fast-jss-manager-react";
import MSFTButton from "@microsoft/fast-components-react-msft/dist/button/button";
import { classNames } from "@microsoft/fast-web-utilities";
import { isNil } from "lodash-es";
import ButtonStyle from "./ButtonStyle";
import { iconToGlyph } from "../Utils/iconToGlyph";
class CustomButton extends Foundation<ButtonHandledProps, ButtonUnhandledProps, {}> {
public static displayName: string = `Button`;
public static defaultProps: Partial<ButtonProps> = {
managedClasses: {},
};
protected handledProps: HandledProps<ButtonHandledProps> = {
href: void 0,
managedClasses: void 0,
disabled: void 0,
icon: void 0,
};
/**
* Renders the component
*/
public render(): React.ReactElement<HTMLButtonElement | HTMLAnchorElement> {
return (
<MSFTButton
{...this.unhandledProps()}
className={this.generateClassNames()}
disabled={this.props.disabled}
href={this.props.href}
beforeContent={this.generateIcon}
>
{this.props.children}
</MSFTButton>
);
}
/**
* Generates class names
*/
protected generateClassNames(): string {
const {
button,
button__disabled,
button__hasIconAndContent,
button__iconOnly,
}: ButtonClassNameContract = this.props.managedClasses;
return super.generateClassNames(
classNames(
button,
[button__disabled, this.props.disabled],
[
(this.props.managedClasses as any)[
`button__${this.props.appearance as string}`
],
typeof this.props.appearance === "string",
],
[button__hasIconAndContent, this.hasIconAndContent()],
[button__iconOnly, this.isIconOnly()]
)
);
}
private generateIcon = (): React.ReactNode => {
return isNil(this.props.icon)
? !isNil(this.props.beforeContent) &&
this.props.beforeContent(classNames(this.props.managedClasses.button_icon))
: iconToGlyph(this.props.icon)(classNames(this.props.managedClasses.button_icon));
};
/**
* Checks whether the button is displaying both icon and content or not
*/
private hasIconAndContent(): boolean {
return !isNil(this.props.icon) && !isNil(this.props.children);
}
/**
* Checks whether the button is displaying only an icon.
* We assume that beforeContent is used for multiple icons.
*/
private isIconOnly(): boolean {
return (
(!isNil(this.props.icon) || !isNil(this.props.beforeContent)) &&
isNil(this.props.children)
);
}
}
const StyledButton = manageJss(ButtonStyle)(CustomButton);
type StyledButton = InstanceType<typeof StyledButton>;
export { StyledButton as Button, ButtonAppearance };

View File

@ -0,0 +1,189 @@
import {
neutralForegroundHover,
neutralForegroundActive,
} from "@microsoft/fast-components-styles-msft";
/**
* Code taken from "@microsoft/fast-components-styles-msft"
* Modified to fit the custom design
*/
import { ButtonClassNameContract } from "@microsoft/fast-components-class-name-contracts-msft";
import { ComponentStyles, CSSRules } from "@microsoft/fast-jss-manager";
import {
directionSwitch,
format,
toPx,
applyFocusVisible,
} from "@microsoft/fast-jss-utilities";
import {
DesignSystem,
accentForegroundCut,
glyphSize,
horizontalSpacing,
focusOutlineWidth,
accentPalette,
backgroundColor,
accentFillHover,
applyPillCornerRadius,
neutralForegroundRest,
neutralFillHover,
highContrastSelected,
highContrastSelectedForeground,
highContrastOutlineFocus,
neutralFocus,
} from "@microsoft/fast-components-styles-msft";
import { ColorRGBA64, rgbToRelativeLuminance } from "@microsoft/fast-colors";
import { parseColorString } from "@microsoft/fast-components-styles-msft/dist/utilities/color/common";
import { getSwatch } from "@microsoft/fast-components-styles-msft/dist/utilities/color/palette";
import { ButtonStyles as MSFTStyle } from "@microsoft/fast-components-styles-msft";
import { mergeDesignSystem } from "@microsoft/fast-jss-manager-react";
/**
* This color should be unaffected by the changes
* between light/dark mode
*/
const applyAccentBackground: CSSRules<DesignSystem> = {
background: ds => getSwatch(45, accentPalette(ds)),
};
const shadowOpacityMultiple = (des: DesignSystem) =>
4 - 3 * rgbToRelativeLuminance(parseColorString(backgroundColor(des))); // white (1) = 1; black (0) = 2;
/**
* Applies shadows to the primary button which give it the pseudo-3d look-and-feel
* @param shadowSize Size of the bottom-border and drop-shadow
*/
const applyPrimaryShadow = (shadowSize: number): CSSRules<DesignSystem> => {
return {
"box-shadow": format(
"0 {0} 0 0 {1}, 0 {2} {3} 0 {4}",
() => toPx(shadowSize),
des => getSwatch(65, accentPalette(des)),
() => toPx(shadowSize + 2),
() => toPx(shadowSize * 1.4),
des => new ColorRGBA64(0, 0, 0, 0.35 * shadowOpacityMultiple(des)).toStringWebRGBA()
),
};
};
const applyBeforeMargin: CSSRules<DesignSystem> = {
"margin-right": directionSwitch(horizontalSpacing(4), ""),
"margin-left": directionSwitch("", horizontalSpacing(4)),
};
const styles: ComponentStyles<ButtonClassNameContract, DesignSystem> = {
button: {
padding: format("6px {0}", horizontalSpacing(focusOutlineWidth)),
background: "transparent",
},
button__primary: {
fill: accentForegroundCut,
color: accentForegroundCut,
...applyAccentBackground,
...applyPrimaryShadow(4),
"font-weight": "bold",
overflow: "visible",
position: "relative",
// More css-weighting, so that generic rules don't break the effect
"&$button": {
"margin-bottom": "4px",
},
// Additional spacing to avoid clipping
"&::before": {
transition: "inherit",
content: "''",
height: "2px",
width: "calc(100% + 4px)", // + 2px border left/right
display: "block",
position: "absolute",
left: "-2px",
top: "-2px", // + 2px border top
},
"&:hover:enabled": {
...applyAccentBackground,
...applyPrimaryShadow(2),
"margin-top": "2px",
"margin-bottom": "2px",
"text-decoration": "none",
"&::before": {
top: "-4px",
},
},
"&:active:enabled": {
...applyAccentBackground,
},
},
button_afterContent: {
"margin-right": directionSwitch("", horizontalSpacing(4)),
"margin-left": directionSwitch(horizontalSpacing(4), ""),
},
button_icon: {
display: "inline-block",
position: "relative",
width: glyphSize,
height: glyphSize,
"flex-shrink": "0",
},
button__hasIconAndContent: {
"& $button_beforeContent, & $button_icon": {
...applyBeforeMargin,
},
},
button__justified: {
"& span": {
position: "relative",
"&::before": {
content: "''",
position: "absolute",
backgroundColor: accentFillHover,
width: "100%",
height: "2px",
bottom: "-4px",
transform: "scaleX(0)",
transformOrigin: "right",
transition: "transform .3s",
},
},
"&:hover span::before": {
transform: "scaleX(1)",
transformOrigin: "left",
},
},
button__lightweight: {
background: "transparent",
color: neutralForegroundRest,
fill: neutralForegroundRest,
"&:hover:enabled, a&:not($button__disabled):hover": {
background: neutralFillHover,
color: neutralForegroundHover,
...highContrastSelected,
"& $button_beforeContent, & $button_afterContent": {
...highContrastSelectedForeground,
},
},
"&:active:enabled, a&:not($button__disabled):active": {
color: neutralForegroundActive,
background: "transparent",
},
...applyFocusVisible<DesignSystem>({
...highContrastOutlineFocus,
"border-color": neutralFocus,
}),
},
button__iconOnly: {
padding: "8px",
margin: "5px",
height: "auto",
...applyPillCornerRadius(),
"& $button_icon": {
width: "17px",
height: "17px",
},
},
};
export default mergeDesignSystem(MSFTStyle, styles);

View File

@ -0,0 +1,64 @@
import { ReactNode } from "react";
import {
ManagedClasses,
CSSRules,
CSSRuleResolver,
CSSStaticRule,
} from "@microsoft/fast-jss-manager-react";
/**
* Class name contract for the header component
*/
export interface HeaderClassNameContract {
/**
* Root of the header component
*/
header: string;
header__fixed?: string;
header__absolute?: string;
/**
* Left-side logo section
*/
header_left: string;
/**
* Center section of the header
*/
header_center?: string;
/**
* Right-side section of the header
*/
header_right?: string;
}
/**
* Props for the header component
*/
export interface HeaderProps extends ManagedClasses<HeaderClassNameContract> {
/**
* Changes the position parameter
* of the main `<header/>` tag
*
* In `fixed` mode, all items below
* the header will receive a `padding-top`
* in the same size as the header
*/
position?: "fixed" | "absolute";
/**
* Content for the left-side section
* of the header
*/
leftContent?: ReactNode;
/**
* Content for the center section
* of the header
*
* Keep in mind that this part will be
* invisible for mobile users due to space constraints.
*/
centerContent?: ReactNode;
/**
* Content for the right-side section
* of the header
*/
rightSideContent?: ReactNode;
}

View File

@ -0,0 +1,121 @@
import React from "react";
import { Paragraph } from "@microsoft/fast-components-react-msft";
import {
DesignSystem,
neutralLayerL3,
neutralLayerL1,
} from "@microsoft/fast-components-styles-msft";
import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react";
import { parseColorHexRGBA } from "@microsoft/fast-colors";
import { HeaderProps, HeaderClassNameContract } from "./Header.props";
import Logo from "../Logo/Logo";
import { Link } from "react-router-dom";
import { classNames } from "@microsoft/fast-web-utilities";
import { applyBackdropBackground } from "../Utils/stylesheetModifiers";
/**
* Hardcoded height of the header.
*/
export const headerHeight: number = 64;
/**
* Generates an acrylic backdrop as well as a gradient background.
*/
const applyBackdropGradient = applyBackdropBackground(
(opacityHex: string) => (designSystem: DesignSystem): string =>
`linear-gradient(180deg, ${parseColorHexRGBA(
neutralLayerL3(designSystem) + "ff"
).toStringWebRGBA()} 1%, ${parseColorHexRGBA(
neutralLayerL1(designSystem) + opacityHex
).toStringWebRGBA()} 98%)`
);
const styles: ComponentStyles<HeaderClassNameContract, DesignSystem> = {
header: {
height: headerHeight + "px",
display: "flex",
alignItems: "center",
width: "100%",
padding: "0 12px",
boxSizing: "border-box",
justifyContent: "space-between",
...applyBackdropGradient,
},
header__fixed: {
position: "fixed",
zIndex: "1",
"& ~ *": {
paddingTop: headerHeight + "px",
},
},
header__absolute: {
position: "absolute",
zIndex: "1",
},
header_left: {
display: "flex",
alignItems: "center",
"&> *": {
marginRight: "7px",
},
},
header_center: {
position: "absolute",
left: "0",
right: "0",
zIndex: "-1",
margin: "auto",
maxWidth: "50%",
maxHeight: "70%",
overflow: "hidden",
textAlign: "center",
},
header_right: {
textAlign: "right",
marginRight: "3px",
},
"@media (max-width: 1000px)": {
header_center: {
position: "initial",
},
},
"@media (max-width: 690px)": {
header_center: {
display: "none",
},
},
};
const HeaderBase: React.FC<HeaderProps> = props => {
return (
<header
className={classNames(
props.managedClasses.header,
[props.managedClasses.header__fixed, props.position === "fixed"],
[props.managedClasses.header__absolute, props.position === "absolute"]
)}
>
<div className={"header-left " + props.managedClasses.header_left}>
<Link to="/">
<Logo size="45" alt="Go to Homepage" />
</Link>
<Paragraph size={2}>Shadis</Paragraph>
{props.leftContent || null}
</div>
{props.centerContent && (
<div
className={"header-center " + props.managedClasses.header_center}
children={props.centerContent}
/>
)}
{props.rightSideContent && (
<div
className={"header-right " + props.managedClasses.header_right}
children={props.rightSideContent}
/>
)}
</header>
);
};
export default manageJss(styles)(HeaderBase);

View File

@ -0,0 +1,29 @@
import { DependencyList, useLayoutEffect } from "react";
import { MotionValue, useMotionValue } from "framer-motion";
/**
* Creates a `MotionValue` that recomputes if one of the `deps` changed.
* It is comparable to `useMemo`.
*
* @param factory Function that returns a new value.
* @param deps List of dependencies the hook should listen to.
*/
export default function useMotionValueFactory<T>(
factory: () => T,
deps: DependencyList
): MotionValue<T> {
const value = useMotionValue<T>(null);
useLayoutEffect(() => {
const listener = () => value.set(factory());
const motionValues: (() => void)[] = [];
listener();
deps.forEach(
dep => dep instanceof MotionValue && motionValues.push(dep.onChange(listener))
);
return () => motionValues.forEach(mv => mv());
}, [value, factory, deps]);
return value;
}

View File

@ -0,0 +1,34 @@
import { useState, useEffect } from "react";
/**
* Listens to a CSS media-query and returns whether it currently matches.
*
* @param breakpoint The breakpoint in pixels the media-query has to watch out for.
* @param queryType The media-query to listen to.
* @param initialValue Use this to avoid flickering due to the state updating after first render.
*/
export const useWindowBreakpoint = (
breakpoint: number,
queryType: "max-width" | "min-width" | "max-height" | "min-height" = "max-width",
initialValue: boolean = false
): boolean => {
const [isBreakpointActive, setBreakpointActivity] = useState(initialValue);
useEffect(() => {
if (typeof window.matchMedia === "undefined") return;
const breakpointListener = (ev: { readonly matches: boolean }) => {
setBreakpointActivity(ev.matches);
};
const mediaQuery = window.matchMedia(`(${queryType}: ${breakpoint}px)`);
breakpointListener(mediaQuery);
mediaQuery.addEventListener("change", breakpointListener);
return () => {
mediaQuery.removeEventListener("change", breakpointListener);
};
}, [breakpoint, queryType]);
return isBreakpointActive;
};

40
src/_DesignSystem/Logo/Logo.tsx Executable file
View File

@ -0,0 +1,40 @@
import React, { Component } from "react";
let CustomLogo: React.ComponentClass<any, any> | React.FunctionComponent<any> = undefined;
/**
* A custom logo can be inserted by creating the module /src/client/_DesignSystem/Assets/Logo.tsx
*
* export: default
* props:
* - size?: string
* return: React.ComponentClass | React.FunctionComponent
*/
try {
const CustomLogoImport: {
default?: React.ComponentClass<any, any> | React.FunctionComponent<any>;
} = require("../Assets/Logo");
CustomLogo = CustomLogoImport.default;
} catch (error) {}
interface LogoProps extends React.ImgHTMLAttributes<HTMLImageElement> {
size?: string;
}
// Falling back to a filler image if no module can be found
export default class Logo extends Component<LogoProps> {
render() {
return typeof CustomLogo !== "undefined" ? (
<CustomLogo {...this.props} />
) : (
<img
src={
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQBAMAAAB8P++eAAAAG1BMVEXMzMyWlpa+vr6cnJy3t7fFxcWjo6OxsbGqqqqN7EKtAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAcklEQVRIiWNgGAWjYBSMglEwsoCysUJzAxoLKzBiMi9oR2NhBSYhxgHBDOVsClAWTqAoqMjAyMDsAWPhBOnhiUBpFlEYCycQZRUGWsguBGPhBCks6UAvBKvBWDhBmFkAMFBa2BygrFEwCkbBKBgFgxoAAFYVFqKYGZ7+AAAAAElFTkSuQmCC"
}
alt={this.props.alt || "Logo"}
width={this.props.size || "80"}
height={this.props.size || "80"}
/>
);
}
}

View File

@ -0,0 +1,19 @@
import { IconType } from "react-icons/lib";
import { ManagedClasses } from "@microsoft/fast-jss-manager-react";
/**
* Class name contract for the component
*/
export interface ProgressIconClassNameContract {
progressIconContainer: string;
progressIcon: string;
}
/**
* Props for the component
*/
export interface ProgressIconProps
extends ManagedClasses<ProgressIconClassNameContract>,
React.HTMLAttributes<HTMLDivElement> {
icon: IconType;
}

View File

@ -0,0 +1,36 @@
import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react";
import { ProgressIconClassNameContract, ProgressIconProps } from "./ProgressIcon.props";
import {
DesignSystem,
neutralForegroundRest,
accentFillRest,
} from "@microsoft/fast-components-styles-msft";
import { FunctionComponent } from "react";
import React from "react";
import { applyCenteredFlexbox } from "../Utils/stylesheetModifiers";
import { classNames } from "@microsoft/fast-web-utilities";
const styles: ComponentStyles<ProgressIconClassNameContract, DesignSystem> = {
progressIconContainer: {
...applyCenteredFlexbox(),
borderRadius: "50%",
width: "32px",
height: "32px",
background: accentFillRest,
},
progressIcon: {
color: neutralForegroundRest,
},
};
const ProgressIcon: FunctionComponent<ProgressIconProps> = props => {
return (
<div
className={classNames(props.managedClasses.progressIconContainer, props.className)}
>
<props.icon className={props.managedClasses.progressIcon} />
</div>
);
};
export default manageJss(styles)(ProgressIcon);

View File

@ -0,0 +1,20 @@
import { ManagedClasses } from "@microsoft/fast-jss-manager-react";
import { ToastProps as OriginalToastProps } from "react-toast-notifications";
/**
* Class name contract for the Toast component
*/
export interface ToastClassNameContract {
toast_element: string;
toast_title: string;
toast_content: string;
}
/**
* Props for the Toast component
*/
export interface ToastProps
extends ManagedClasses<ToastClassNameContract>,
OriginalToastProps {
title?: string;
}

View File

@ -0,0 +1,80 @@
import React from "react";
import { DefaultToast } from "react-toast-notifications";
import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react";
import {
DesignSystem,
neutralLayerFloating,
applyElevation,
ElevationMultiplier,
applyFloatingCornerRadius,
neutralForegroundRest,
applyFontWeightBold,
} from "@microsoft/fast-components-styles-msft";
import { ToastProps, ToastClassNameContract } from "./Toast.props";
import { useTranslation } from "react-i18next";
const toastStyles: ComponentStyles<ToastClassNameContract, DesignSystem> = {
toast_element: {
"& > div > div": {
alignItems: "center",
backgroundColor: (des: DesignSystem) => neutralLayerFloating(des),
...applyFloatingCornerRadius(),
...applyElevation(ElevationMultiplier.e9),
"& > .react-toast-notifications__toast__icon-wrapper": {
"border-top-left-radius": "0",
"border-bottom-left-radius": "0",
"padding-left": "5px",
"padding-right": "5px",
"& > .react-toast-notifications__toast__countdown": {
backgroundColor: "rgba(0,0,0,0.12)",
},
},
"& > .react-toast-notifications__toast__content": {
display: "flex",
flexDirection: "column",
justifyContent: "center",
lineHeight: "1.2",
},
"& > .react-toast-notifications__toast__dismiss-button": {
color: (des: DesignSystem) => neutralForegroundRest(des),
},
},
},
toast_title: {
color: (des: DesignSystem) => neutralForegroundRest(des),
...applyFontWeightBold(),
},
toast_content: {
color: (des: DesignSystem) => neutralForegroundRest(des),
},
};
const BaseToast = (props: ToastProps) => {
const { t } = useTranslation("common");
let defaultToastProps = { ...props };
delete defaultToastProps.managedClasses;
delete defaultToastProps.title;
const title = () => {
switch (props.appearance) {
case "error":
return t("notification.error");
case "warning":
return t("notification.warning");
default:
return t("notification.info");
}
};
return (
<div className={props.managedClasses.toast_element}>
<DefaultToast {...defaultToastProps}>
<span className={props.managedClasses.toast_title}>{props.title || title()}</span>
<span className={props.managedClasses.toast_content}>{props.children}</span>
</DefaultToast>
</div>
);
};
export default manageJss(toastStyles)(BaseToast);

View File

@ -0,0 +1,16 @@
import { ManagedClasses } from "@microsoft/fast-jss-manager-react";
import { ToastContainerProps as OriginalToastContainerProps } from "react-toast-notifications";
/**
* Class name contract for the ToastContainer component
*/
export interface ToastContainerClassNameContract {
toast_container: string;
}
/**
* Props for the ToastContainer component
*/
export interface ToastContainerProps
extends ManagedClasses<ToastContainerClassNameContract>,
OriginalToastContainerProps {}

View File

@ -0,0 +1,35 @@
import React from "react";
import { DefaultToastContainer } from "react-toast-notifications";
import manageJss, { ComponentStyles } from "@microsoft/fast-jss-manager-react";
import { DesignSystem } from "@microsoft/fast-components-styles-msft";
import {
ToastContainerProps,
ToastContainerClassNameContract,
} from "./ToastContainer.props";
const toastContainerStyles: ComponentStyles<
ToastContainerClassNameContract,
DesignSystem
> = {
toast_container: {
"& > div": {
overflowY: "visible",
overflowX: "visible",
},
},
};
const BaseToastContainer = (props: ToastContainerProps) => {
let defaultToastContainerProps = { ...props };
delete defaultToastContainerProps.managedClasses;
return (
<div className={props.managedClasses.toast_container}>
<DefaultToastContainer {...defaultToastContainerProps}>
{props.children}
</DefaultToastContainer>
</div>
);
};
export default manageJss(toastContainerStyles)(BaseToastContainer);

View File

@ -0,0 +1,41 @@
import {
useToasts as originalUseToasts,
RemoveToast,
RemoveAllToasts,
AppearanceTypes,
UpdateToast,
Options,
} from "react-toast-notifications";
import { ReactNode } from "react";
/**
* Since a custom "title" prop is available in the toast,
* The type definition needs to be updated.
*
* @interface CustomOptions
* @extends {Options}
*/
interface CustomOptions extends Options {
title?: string;
}
type AddToast = (
content: ReactNode,
options?: CustomOptions,
callback?: (id: string) => void
) => void;
type UseToasts = () => {
addToast: AddToast;
removeToast: RemoveToast;
removeAllToasts: RemoveAllToasts;
toastStack: Array<{
content: ReactNode;
id: string;
appearance: AppearanceTypes;
}>;
updateToast: UpdateToast;
};
const useToasts: UseToasts = originalUseToasts;
export default useToasts;

Some files were not shown because too many files have changed in this diff Show More