+ CRA rewired for PHP support
This commit is contained in:
parent
a02d7c7612
commit
e4be2cbba3
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "submodules/getID3"]
|
||||
path = submodules/getID3
|
||||
url = https://github.com/JamesHeinrich/getID3
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"arrowParens": "avoid"
|
||||
}
|
34
README.md
34
README.md
|
@ -1,3 +1,33 @@
|
|||
# Shadis
|
||||
ShareX Distribution System
|
||||
TODO: Better README
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
# Development
|
||||
|
||||
This project uses React for the front-end and PHP for the back-end. You can find the front-end code in `src` and the back-end code in `public`.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start` or `yarn serve`
|
||||
|
||||
Runs the app in the development mode and builds it to the `dist` folder.<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/).
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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!");
|
|
@ -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);
|
|
@ -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));
|
|
@ -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();
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
session_start();
|
||||
session_unset();
|
||||
session_destroy();
|
||||
header('Location: ../');
|
||||
exit();
|
|
@ -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);
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
|
@ -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";
|
|
@ -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();
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow: /
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
|
@ -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"),
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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;
|
|
@ -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> {}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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));
|
|
@ -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> {}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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"];
|
||||
}
|
|
@ -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);
|
|
@ -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"];
|
||||
}
|
|
@ -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);
|
|
@ -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"];
|
||||
}
|
|
@ -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);
|
|
@ -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>>;
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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"];
|
||||
}
|
|
@ -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);
|
|
@ -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"];
|
||||
}
|
|
@ -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);
|
|
@ -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> {}
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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];
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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));
|
|
@ -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> {}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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[];
|
||||
};
|
||||
}
|
|
@ -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);
|
|
@ -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> {}
|
|
@ -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 />,
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
export * from "./FullscreenLoader";
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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> {}
|
|
@ -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);
|
|
@ -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;
|
|
@ -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 };
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
|
@ -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 {}
|
|
@ -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);
|
|
@ -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
Loading…
Reference in New Issue