+ 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
|
# 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