Compare commits

...
32 Commits
Author SHA1 Message Date
lukas 2967aee16d Merge branch 'keypropwarnings' into 'master'
fix some required key warnings

See merge request lukas/openmediacenter!34
2021-02-06 22:18:48 +00:00
lukas 3c32356227 improve insertion of reindex messages -- prevent empty string lines and add key prop to every new line 2021-02-06 22:18:48 +00:00
lukas 46aeda73d8 Merge branch 'api_call_enum' into 'master'
add API node type instead of always use string to define api node

See merge request lukas/openmediacenter!33
2021-01-29 22:15:17 +00:00
lukas b6ab359a37 add API node type instead of always use string to define api node 2021-01-29 22:15:17 +00:00
lukas e825f94028 fix #54 2021-01-29 21:39:19 +01:00
lukas fbf286c09c Merge branch 'enter_popup_submit' into 'master'
Shortkeys

See merge request lukas/openmediacenter!31
2021-01-28 19:50:26 +00:00
lukas fa21ba4f25 bind enter events as a submit to Popups
add s as key to submit a reshuffle in shuffled videos
2021-01-28 19:50:26 +00:00
lukas f06da8044f Merge branch 'tat_remove' into 'master'
make tags deleteable

See merge request lukas/openmediacenter!30
2021-01-26 19:14:57 +00:00
lukas ac126f6a9d make tags deleteable
seperate sidebar for each different category page
2021-01-26 19:14:57 +00:00
lukas d8aee9e5b7 reformat code 2021-01-24 16:43:38 +01:00
lukas fe1a00d1af Merge branch 'full_typescript' into 'master'
typescriptify settings components

See merge request lukas/openmediacenter!29
2021-01-22 21:05:22 +00:00
lukas 6c7cc11038 typescriptify Popupbase
focus textfield on filterclick
2021-01-22 21:05:21 +00:00
lukas 66eb72d7fb drop support of electron - bad practise to mix website with electron app 2021-01-19 17:27:39 +01:00
lukas 0c3f9204bc keep persistent api - databse namings in tests (fixes tests) 2021-01-06 17:53:57 +01:00
lukas 4ca590639d add a filter option to the addactor popup page 2021-01-03 21:58:55 +01:00
lukas 3e9cb7410f use / as default homepage in package.json 2021-01-01 19:49:18 +01:00
lukas 272c88ab50 force index.html - independent of subpath 2020-12-30 18:12:41 +01:00
lukas c4227faf14 Merge branch 'reactrouter' into 'master'
use react router to route through pages

See merge request lukas/openmediacenter!28
2020-12-29 19:39:30 +00:00
lukas 80a04456e6 fix some tests
fix merge issues
2020-12-29 19:39:30 +00:00
lukas e11f021efe we need a prepare state because some jobs havent an node env.
use node14 anyways
2020-12-22 18:55:45 +01:00
lukas 60b14b3c0d add package-lock.json to project 2020-12-22 18:26:50 +01:00
lukas 350471363e new way to initialize npm packages 2020-12-22 17:42:03 +01:00
lukas bce4ec49da no colored cicd -- no coverage 2020-12-21 19:59:30 +00:00
lukas 4b664d0ae6 correct naming of gitlabci needs 2020-12-18 20:19:32 +01:00
lukas e075a87750 rename gitlab jobs and use only one testing job 2020-12-18 19:55:47 +01:00
lukas 866d8f72b4 Merge branch 'electronapp' into 'master'
Electronapp

See merge request lukas/openmediacenter!27
2020-12-17 20:53:22 +00:00
lukas 7d696122fa build electron app
implement new fetch api calls
use typescript
2020-12-17 20:53:22 +00:00
lukas c049aa345c Merge branch 'actors_page' into 'master'
Actor Page and Tiles on Player page

Closes #50

See merge request lukas/openmediacenter!23
2020-12-11 18:23:14 +00:00
lukas c5d231d9f2 add style for actor tiles
render actors got from backend
backend test code to get actors
2020-12-11 18:23:13 +00:00
lukas 707c54e5f5 Merge branch 'react17plyr3' into 'master'
React17plyr3

See merge request lukas/openmediacenter!26
2020-11-27 22:43:12 +00:00
lukas 2d8bb06852 update all but react-scripts (wrong execution of unit tests so they are failing)
fix failing tests because of missing implementation of mount() in enzyme for react 17
2020-11-27 22:43:12 +00:00
lukas 2ae00f8af0 release version 0.1.2 2020-11-20 22:00:09 +01:00
102 changed files with 23217 additions and 2022 deletions

No files matched your search

+14 -22
View File
@@ -8,7 +8,9 @@ stages:
- deploy
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .npm/
- node_modules/
include:
@@ -17,12 +19,12 @@ include:
variables:
SAST_DISABLE_DIND: "true"
prepare:
Node_dependencies:
stage: prepare
script:
- npm install --progress=false
- npm ci --cache .npm --prefer-offline
build:
Minimize:
stage: build
script:
- npm run build
@@ -30,33 +32,23 @@ build:
expire_in: 7 days
paths:
- build/
needs: ["prepare"]
needs: ["Node_dependencies"]
test:
Frontend_Tests:
stage: test
script:
- CI=true npm run test
- npm run test
artifacts:
reports:
junit:
- ./junit.xml
needs: ["prepare"]
needs: ["Node_dependencies"]
code_quality:
tags:
- dind
coverage:
stage: test
script:
- CI=true npm run coverage
artifacts:
reports:
cobertura:
- ./coverage/cobertura-coverage.xml
needs: ["prepare"]
package_debian:
Debian_Server:
stage: packaging
image: debian
script:
@@ -74,14 +66,14 @@ package_debian:
artifacts:
paths:
- deb/OpenMediaCenter-*.deb
needs: ["build"]
needs: ["Minimize"]
deploy_test1:
Test_Server:
stage: deploy
image: luki42/alpineopenssh:latest
needs:
- test
- package_debian
- Frontend_Tests
- Debian_Server
only:
- master
script:
+5
View File
@@ -0,0 +1,5 @@
<?php
include_once __DIR__ . '/src/handlers/Actor.php';
$actor = new Actor();
$actor->handleAction();
+75
View File
@@ -0,0 +1,75 @@
<?php
require_once __DIR__ . '/../SSettings.php';
require_once 'RequestBase.php';
class Actor extends RequestBase {
function initHandlers() {
$this->databaseAdds();
$this->databaseRequests();
}
function databaseAdds() {
$this->addActionHandler("createActor", function () {
// skip tag create if already existing
$actorname = $_POST["actorname"];
$query = "INSERT IGNORE INTO actors (name) VALUES ('$actorname')";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
});
$this->addActionHandler("addActorToVideo", function () {
// skip tag create if already existing
$actorid = $_POST["actorid"];
$videoid = $_POST["videoid"];
$query = "INSERT IGNORE INTO actors_videos (actor_id, video_id) VALUES ($actorid,$videoid)";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
});
}
function databaseRequests() {
$this->addActionHandler("getAllActors", function () {
// query the actors corresponding to video
$query = "SELECT * FROM actors";
$result = $this->conn->query($query);
$this->commitMessage(json_encode(mysqli_fetch_all($result, MYSQLI_ASSOC)));
});
$this->addActionHandler("getActorsOfVideo", function () {
// query the actors corresponding to video
$video_id = $_POST["videoid"];
$query = "SELECT a.actor_id, name, thumbnail FROM actors_videos
JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=$video_id";
$result = $this->conn->query($query);
$this->commitMessage(json_encode(mysqli_fetch_all($result, MYSQLI_ASSOC)));
});
$this->addActionHandler("getActorInfo", function (){
$actorid = $_POST["actorid"];
$query = "SELECT movie_id, movie_name FROM actors_videos
JOIN videos v on v.movie_id = actors_videos.video_id
WHERE actors_videos.actor_id=$actorid";
$result = $this->conn->query($query);
$actorinfo = $this->conn->query("SELECT name, thumbnail, actor_id FROM actors WHERE actor_id=$actorid");
$reply = array("videos" => mysqli_fetch_all($result, MYSQLI_ASSOC), "info" => mysqli_fetch_assoc($actorinfo));
$this->commitMessage(json_encode($reply));
});
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ abstract class RequestBase {
// call the right handler
$this->actions[$action]();
} else {
echo('{data:"error"}');
$this->commitMessage('{"data": "error"}');
}
}
+4 -4
View File
@@ -98,9 +98,9 @@ class Settings extends RequestBase {
WHERE 1";
if ($this->conn->query($query) === true) {
$this->commitMessage('{"success": true}');
$this->commitMessage('{"result": "success"}');
} else {
$this->commitMessage('{"success": true}');
$this->commitMessage('{"result": "success"}');
}
});
}
@@ -127,9 +127,9 @@ class Settings extends RequestBase {
$cmd = 'php extractvideopreviews.php';
exec(sprintf("%s > %s 2>&1 & echo $! >> %s", $cmd, '/dev/zero', '/tmp/openmediacenterpid'));
$this->commitMessage('{"success": true}');
$this->commitMessage('{"result": "success"}');
} else {
$this->commitMessage('{"success": false}');
$this->commitMessage('{"result": "success"}');
}
});
+33
View File
@@ -9,6 +9,7 @@ class Tags extends RequestBase {
function initHandlers() {
$this->addToDB();
$this->getFromDB();
$this->delete();
}
private function addToDB() {
@@ -65,4 +66,36 @@ class Tags extends RequestBase {
$this->commitMessage(json_encode($rows));
});
}
private function delete() {
/**
* delete a Tag with specified id
*/
$this->addActionHandler("deleteTag", function () {
$tag_id = $_POST['tagId'];
$force = $_POST['force'];
// delete key constraints first
if ($force === "true") {
$query = "DELETE FROM video_tags WHERE tag_id=$tag_id";
if ($this->conn->query($query) !== TRUE) {
$this->commitMessage('{"result":"' . $this->conn->error . '"}');
}
}
$query = "DELETE FROM tags WHERE tag_id=$tag_id";
if ($this->conn->query($query) === TRUE) {
$this->commitMessage('{"result":"success"}');
} else {
// check if error is a constraint error
if (preg_match('/^.*a foreign key constraint fails.*$/i', $this->conn->error)) {
$this->commitMessage('{"result":"not empty tag"}');
} else {
$this->commitMessage('{"result":"' . $this->conn->eror . '"}');
}
}
});
}
}
+14 -6
View File
@@ -34,7 +34,7 @@ class Video extends RequestBase {
$query = "SELECT movie_id,movie_name FROM videos
INNER JOIN video_tags vt on videos.movie_id = vt.video_id
INNER JOIN tags t on vt.tag_id = t.tag_id
WHERE t.tag_name = '$tag'
WHERE t.tag_id = '$tag'
ORDER BY likes DESC, create_date, movie_name";
}
}
@@ -63,13 +63,13 @@ class Video extends RequestBase {
$idstring = implode(" OR ", $ids);
$return->tags = array();
$query = "SELECT t.tag_name FROM video_tags
$query = "SELECT t.tag_name,t.tag_id FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE $idstring
GROUP BY t.tag_name";
GROUP BY t.tag_id";
$result = $this->conn->query($query);
while ($r = mysqli_fetch_assoc($result)) {
array_push($return->tags, $r);
array_push($return->tags, array('tag_name' => $r['tag_name'], 'tag_id' => $r['tag_id']));
}
$this->commitMessage(json_encode($return));
@@ -98,6 +98,7 @@ class Video extends RequestBase {
$this->addActionHandler("loadVideo", function () {
$video_id = $_POST['movieid'];
// todo join with actor db and add actors of movieid
$query = " SELECT movie_name,movie_id,movie_url,thumbnail,poster,likes,quality,length
FROM videos WHERE movie_id=$video_id";
@@ -122,10 +123,10 @@ class Video extends RequestBase {
// load tags of this video
$arr['tags'] = array();
$query = "SELECT t.tag_name FROM video_tags
$query = "SELECT t.tag_name, t.tag_id FROM video_tags
INNER JOIN tags t on video_tags.tag_id = t.tag_id
WHERE video_tags.video_id=$video_id
GROUP BY t.tag_name";
GROUP BY t.tag_id";
$result = $this->conn->query($query);
while ($r = mysqli_fetch_assoc($result)) {
array_push($arr['tags'], $r);
@@ -145,6 +146,13 @@ class Video extends RequestBase {
array_push($arr['suggesttag'], $r);
}
// query the actors corresponding to video
$query = "SELECT a.actor_id, name, thumbnail FROM actors_videos
JOIN actors a on actors_videos.actor_id = a.actor_id
WHERE actors_videos.video_id=$video_id";
$result = $this->conn->query($query);
$arr['actors'] = mysqli_fetch_all($result, MYSQLI_ASSOC);
$this->commitMessage(json_encode($arr));
});
+37 -11
View File
@@ -1,3 +1,22 @@
create table if not exists actors
(
actor_id int auto_increment
primary key,
name varchar(50) null,
thumbnail mediumblob null
)
comment 'informations about different actors';
create table if not exists settings
(
video_path varchar(255) null,
episode_path varchar(255) null,
password varchar(32) null,
mediacenter_name varchar(32) null,
TMDB_grabbing tinyint null,
DarkMode tinyint default 0 null
);
create table if not exists tags
(
tag_id int auto_increment
@@ -12,13 +31,29 @@ create table if not exists videos
movie_name varchar(200) null,
movie_url varchar(250) null,
thumbnail mediumblob null,
poster mediumblob null,
likes int default 0 null,
create_date datetime default CURRENT_TIMESTAMP null,
quality int null,
length int null comment 'in seconds',
create_date datetime default CURRENT_TIMESTAMP null
poster mediumblob null
);
create table if not exists actors_videos
(
actor_id int null,
video_id int null,
constraint actors_videos_actors_id_fk
foreign key (actor_id) references actors (actor_id),
constraint actors_videos_videos_movie_id_fk
foreign key (video_id) references videos (movie_id)
);
create index actors_videos_actor_id_index
on actors_videos (actor_id);
create index actors_videos_video_id_index
on actors_videos (video_id);
create table if not exists video_tags
(
tag_id int null,
@@ -30,15 +65,6 @@ create table if not exists video_tags
on delete cascade
);
create table if not exists settings
(
video_path varchar(255) null,
episode_path varchar(255) null,
password varchar(32) default '-1' null,
mediacenter_name varchar(32) default 'OpenMediaCenter' null,
TMDB_grabbing tinyint null,
DarkMode tinyint default 0 null
);
INSERT IGNORE INTO tags (tag_id, tag_name)
VALUES (2, 'fullhd');
@@ -2,12 +2,6 @@ server {
listen 8080 default_server;
listen [::]:8080 default_server;
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php-fpm.sock;
}
root /var/www/openmediacenter;
index index.html;
@@ -16,6 +10,12 @@ server {
error_log /var/log/nginx/openmediacenter.error.log;
location / {
try_files $uri $uri/ =404;
try_files $uri /index.html;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php-fpm.sock;
}
}
+1
View File
@@ -0,0 +1 @@
declare module '*.css';
+19522
View File
File diff suppressed because it is too large. Load diff
+43 -16
View File
@@ -1,25 +1,31 @@
{
"name": "openmediacenter",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"main": "public/electron.js",
"author": {
"email": "lukas.heiligenbrunner@gmail.com",
"name": "Lukas Heiligenbrunner",
"url": "https://heili.eu"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.30",
"@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-regular-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/react-fontawesome": "^0.1.11",
"@fortawesome/react-fontawesome": "^0.1.13",
"bootstrap": "^4.5.3",
"plyr-react": "^2.2.0",
"react": "^16.14.0",
"plyr-react": "^3.0.7",
"react": "^17.0.1",
"react-bootstrap": "^1.4.0",
"react-dom": "^16.14.0",
"react-scripts": "^3.4.4"
"react-dom": "^17.0.1",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"typescript": "^4.1.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --reporters=jest-junit --reporters=default",
"coverage": "react-scripts test --coverage --watchAll=false",
"eject": "react-scripts eject"
"test": "CI=true react-scripts test --reporters=jest-junit --verbose --silent --coverage --reporters=default"
},
"jest": {
"collectCoverageFrom": [
@@ -31,10 +37,24 @@
"text-summary"
]
},
"proxy": "http://192.168.0.42:8080",
"proxy": "http://192.168.0.209",
"homepage": "/",
"eslintConfig": {
"extends": "react-app"
"extends": [
"react-app",
"react-app/jest"
],
"overrides": [
{
"files": [
"**/*.ts?(x)"
],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "error"
}
}
]
},
"browserslist": {
"production": [
@@ -49,11 +69,18 @@
]
},
"devDependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1",
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^12.6.0",
"@types/react-router-dom": "^5.1.6",
"@types/react-router": "5.1.8",
"@types/jest": "^26.0.19",
"@types/node": "^12.19.9",
"@types/react": "^16.14.2",
"@types/react-dom": "^16.9.10",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.5",
"jest-junit": "^10.0.0"
"jest-junit": "^12.0.0",
"react-scripts": "4.0.1"
}
}
-144
View File
@@ -1,144 +0,0 @@
import React from 'react';
import HomePage from './pages/HomePage/HomePage';
import RandomPage from './pages/RandomPage/RandomPage';
import GlobalInfos from './GlobalInfos';
// include bootstraps css
import 'bootstrap/dist/css/bootstrap.min.css';
import style from './App.module.css';
import SettingsPage from './pages/SettingsPage/SettingsPage';
import CategoryPage from './pages/CategoryPage/CategoryPage';
/**
* The main App handles the main tabs and which content to show
*/
class App extends React.Component {
newElement = null;
constructor(props, context) {
super(props, context);
this.state = {
page: 'default',
generalSettingsLoaded: false,
passwordsupport: null,
mediacentername: 'OpenMediaCenter'
};
// bind this to the method for being able to call methods such as this.setstate
this.changeRootElement = this.changeRootElement.bind(this);
this.returnToLastElement = this.returnToLastElement.bind(this);
}
componentDidMount() {
const updateRequest = new FormData();
updateRequest.append('action', 'loadInitialData');
fetch('/api/settings.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
// set theme
GlobalInfos.enableDarkTheme(result.DarkMode);
this.setState({
generalSettingsLoaded: true,
passwordsupport: result.passwordEnabled,
mediacentername: result.mediacenter_name
});
// set tab title to received mediacenter name
document.title = result.mediacenter_name;
}));
}
/**
* create a viewbinding to call APP functions from child elements
* @returns a set of callback functions
*/
constructViewBinding() {
return {
changeRootElement: this.changeRootElement,
returnToLastElement: this.returnToLastElement
};
}
/**
* load the selected component into the main view
* @returns {JSX.Element} body element of selected page
*/
MainBody() {
let page;
if (this.state.page === 'default') {
page = <HomePage viewbinding={this.constructViewBinding()}/>;
this.mypage = page;
} else if (this.state.page === 'random') {
page = <RandomPage viewbinding={this.constructViewBinding()}/>;
this.mypage = page;
} else if (this.state.page === 'settings') {
page = <SettingsPage/>;
this.mypage = page;
} else if (this.state.page === 'categories') {
page = <CategoryPage viewbinding={this.constructViewBinding()}/>;
this.mypage = page;
} else if (this.state.page === 'video') {
// show videoelement if neccessary
page = this.newElement;
console.log(page);
} else if (this.state.page === 'lastpage') {
// return back to last page
page = this.mypage;
} else {
page = <div>unimplemented yet!</div>;
}
return (page);
}
render() {
const themeStyle = GlobalInfos.getThemeStyle();
// add the main theme to the page body
document.body.className = themeStyle.backgroundcolor;
return (
<div className={style.app}>
<div className={[style.navcontainer, themeStyle.backgroundcolor, themeStyle.textcolor, themeStyle.hrcolor].join(' ')}>
<div className={style.navbrand}>{this.state.mediacentername}</div>
<div className={[style.navitem, themeStyle.navitem, this.state.page === 'default' ? style.navitemselected : {}].join(' ')}
onClick={() => this.setState({page: 'default'})}>Home
</div>
<div className={[style.navitem, themeStyle.navitem, this.state.page === 'random' ? style.navitemselected : {}].join(' ')}
onClick={() => this.setState({page: 'random'})}>Random Video
</div>
<div className={[style.navitem, themeStyle.navitem, this.state.page === 'categories' ? style.navitemselected : {}].join(' ')}
onClick={() => this.setState({page: 'categories'})}>Categories
</div>
<div className={[style.navitem, themeStyle.navitem, this.state.page === 'settings' ? style.navitemselected : {}].join(' ')}
onClick={() => this.setState({page: 'settings'})}>Settings
</div>
</div>
{this.state.generalSettingsLoaded ? this.MainBody() : 'loading'}
</div>
);
}
/**
* render a new root element into the main body
*/
changeRootElement(element) {
this.newElement = element;
this.setState({
page: 'video'
});
}
/**
* return from page to the previous page before a change
*/
returnToLastElement() {
this.setState({
page: 'lastpage'
});
}
}
export default App;
+1
View File
@@ -16,6 +16,7 @@
.navitem:hover {
opacity: 1;
text-decoration: none;
transition: opacity .5s;
}
-65
View File
@@ -18,71 +18,6 @@ describe('<App/>', function () {
expect(wrapper.find('.navitem')).toHaveLength(4);
});
it('simulate video view change ', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.instance().changeRootElement(<div id='testit'/>);
expect(wrapper.find('#testit')).toHaveLength(1);
});
it('test hide video again', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.instance().changeRootElement(<div id='testit'/>);
expect(wrapper.find('#testit')).toHaveLength(1);
wrapper.instance().returnToLastElement();
expect(wrapper.find('HomePage')).toHaveLength(1);
});
it('test fallback to last loaded page', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.find('.navitem').findWhere(t => t.text() === 'Random Video' && t.type() === 'div').simulate('click');
wrapper.instance().changeRootElement(<div id='testit'/>);
expect(wrapper.find('#testit')).toHaveLength(1);
wrapper.instance().returnToLastElement();
expect(wrapper.find('RandomPage')).toHaveLength(1);
});
it('test home click', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
wrapper.setState({page: 'wrongvalue'});
expect(wrapper.find('HomePage')).toHaveLength(0);
wrapper.find('.navitem').findWhere(t => t.text() === 'Home' && t.type() === 'div').simulate('click');
expect(wrapper.find('HomePage')).toHaveLength(1);
});
it('test category click', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
expect(wrapper.find('CategoryPage')).toHaveLength(0);
wrapper.find('.navitem').findWhere(t => t.text() === 'Categories' && t.type() === 'div').simulate('click');
expect(wrapper.find('CategoryPage')).toHaveLength(1);
});
it('test settings click', function () {
const wrapper = shallow(<App/>);
wrapper.setState({generalSettingsLoaded: true}); // simulate fetch to have already finisheed
expect(wrapper.find('SettingsPage')).toHaveLength(0);
wrapper.find('.navitem').findWhere(t => t.text() === 'Settings' && t.type() === 'div').simulate('click');
expect(wrapper.find('SettingsPage')).toHaveLength(1);
});
it('test initial fetch from api', done => {
global.fetch = global.prepareFetchApi({
generalSettingsLoaded: true,
+124
View File
@@ -0,0 +1,124 @@
import React from 'react';
import HomePage from './pages/HomePage/HomePage';
import RandomPage from './pages/RandomPage/RandomPage';
import GlobalInfos from './utils/GlobalInfos';
// include bootstraps css
import 'bootstrap/dist/css/bootstrap.min.css';
import style from './App.module.css';
import SettingsPage from './pages/SettingsPage/SettingsPage';
import CategoryPage from './pages/CategoryPage/CategoryPage';
import {APINode, callAPI} from './utils/Api';
import {NoBackendConnectionPopup} from './elements/Popups/NoBackendConnectionPopup/NoBackendConnectionPopup';
import {BrowserRouter as Router, NavLink, Route, Switch} from 'react-router-dom';
import Player from './pages/Player/Player';
import ActorOverviewPage from './pages/ActorOverviewPage/ActorOverviewPage';
import ActorPage from './pages/ActorPage/ActorPage';
import {SettingsTypes} from './types/ApiTypes';
interface state {
generalSettingsLoaded: boolean;
passwordsupport: boolean;
mediacentername: string;
onapierror: boolean;
}
/**
* The main App handles the main tabs and which content to show
*/
class App extends React.Component<{}, state> {
constructor(props: {}) {
super(props);
this.state = {
generalSettingsLoaded: false,
passwordsupport: false,
mediacentername: 'OpenMediaCenter',
onapierror: false
};
}
initialAPICall(): void {
// this is the first api call so if it fails we know there is no connection to backend
callAPI(APINode.Settings, {action: 'loadInitialData'}, (result: SettingsTypes.initialApiCallData) => {
// set theme
GlobalInfos.enableDarkTheme(result.DarkMode);
this.setState({
generalSettingsLoaded: true,
passwordsupport: result.passwordEnabled,
mediacentername: result.mediacenter_name,
onapierror: false
});
// set tab title to received mediacenter name
document.title = result.mediacenter_name;
}, error => {
this.setState({onapierror: true});
});
}
componentDidMount(): void {
this.initialAPICall();
}
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
// add the main theme to the page body
document.body.className = themeStyle.backgroundcolor;
return (
<Router>
<div className={style.app}>
<div className={[style.navcontainer, themeStyle.backgroundcolor, themeStyle.textcolor, themeStyle.hrcolor].join(' ')}>
<div className={style.navbrand}>{this.state.mediacentername}</div>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/'} activeStyle={{opacity: '0.85'}}>Home</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/random'} activeStyle={{opacity: '0.85'}}>Random
Video</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/categories'} activeStyle={{opacity: '0.85'}}>Categories</NavLink>
<NavLink className={[style.navitem, themeStyle.navitem].join(' ')} to={'/settings'} activeStyle={{opacity: '0.85'}}>Settings</NavLink>
</div>
{this.routing()}
</div>
{this.state.onapierror ? this.ApiError() : null}
</Router>
);
}
routing(): JSX.Element {
return (
<Switch>
<Route path="/random">
<RandomPage/>
</Route>
<Route path="/categories">
<CategoryPage/>
</Route>
<Route path="/settings">
<SettingsPage/>
</Route>
<Route exact path="/player/:id">
<Player/>
</Route>
<Route exact path="/actors">
<ActorOverviewPage/>
</Route>
<Route path="/actors/:id">
<ActorPage/>
</Route>
<Route path="/">
<HomePage/>
</Route>
</Switch>
);
}
ApiError(): JSX.Element {
// on api error show popup and retry and show again if failing..
return (<NoBackendConnectionPopup onHide={(): void => this.initialAPICall()}/>);
}
}
export default App;
+8
View File
@@ -22,6 +22,14 @@
background: white;
}
.navitem {
color: white;
}
.navitem:hover {
color: white;
}
.hrcolor {
border-color: rgba(255, 255, 255, .1);
}
+8
View File
@@ -2,6 +2,14 @@
* The coloring elements for light theme
*/
.navitem {
color: black;
}
.navitem:hover {
color: black;
}
.navitem::after {
background: black;
}
@@ -0,0 +1,29 @@
.actortile {
background-color: #179017;
border-radius: 10px;
cursor: pointer;
float: left;
height: 200px;
margin: 3px;
transition: opacity ease 0.5s;
width: 130px;
}
.actortile:hover {
opacity: 0.7;
transition: opacity ease 0.5s;
}
.actortile_thumbnail {
color: #c6c6c6;
height: 160px;
margin-top: 10px;
text-align: center;
}
.actortile_name {
color: white;
text-align: center;
/*todo dynamic text coloring dependent on theme*/
}
+23
View File
@@ -0,0 +1,23 @@
import {shallow} from 'enzyme';
import React from 'react';
import ActorTile from './ActorTile';
describe('<ActorTile/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<ActorTile actor={{thumbnail: '-1', name: 'testname', id: 3}}/>);
wrapper.unmount();
});
it('simulate click with custom handler', function () {
const func = jest.fn((_) => {});
const wrapper = shallow(<ActorTile actor={{thumbnail: '-1', name: 'testname', id: 3}} onClick={() => func()}/>);
const func1 = jest.fn();
prepareViewBinding(func1);
wrapper.simulate('click');
expect(func1).toBeCalledTimes(0);
expect(func).toBeCalledTimes(1);
});
});
+47
View File
@@ -0,0 +1,47 @@
import style from './ActorTile.module.css';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faUser} from '@fortawesome/free-solid-svg-icons';
import React from 'react';
import {Link} from 'react-router-dom';
import {ActorType} from '../../types/VideoTypes';
interface props {
actor: ActorType;
onClick?: (actor: ActorType) => void
}
class ActorTile extends React.Component<props> {
constructor(props: props) {
super(props);
this.state = {};
}
render(): JSX.Element {
if (this.props.onClick) {
return this.renderActorTile(this.props.onClick);
} else {
return (
<Link to={{pathname: '/actors/' + this.props.actor.actor_id}}>
{this.renderActorTile(() => {
})}
</Link>
);
}
}
renderActorTile(customclickhandler: (actor: ActorType) => void): JSX.Element {
return (
<div className={style.actortile} onClick={(): void => customclickhandler(this.props.actor)}>
<div className={style.actortile_thumbnail}>
{this.props.actor.thumbnail === null ? <FontAwesomeIcon style={{
lineHeight: '130px'
}} icon={faUser} size='5x'/> : 'dfdf' /* todo render picture provided here! */}
</div>
<div className={style.actortile_name}>{this.props.actor.name}</div>
</div>
);
}
}
export default ActorTile;
-157
View File
@@ -1,157 +0,0 @@
import React from 'react';
import ReactDom from 'react-dom';
import style from './AddTagPopup.module.css';
import Tag from '../Tag/Tag';
import {Line} from '../PageTitle/PageTitle';
import GlobalInfos from '../../GlobalInfos';
/**
* component creates overlay to add a new tag to a video
*/
class AddTagPopup extends React.Component {
/// instance of root element
element;
constructor(props, context) {
super(props, context);
this.state = {items: []};
this.handleClickOutside = this.handleClickOutside.bind(this);
this.keypress = this.keypress.bind(this);
this.props = props;
}
componentDidMount() {
document.addEventListener('click', this.handleClickOutside);
document.addEventListener('keyup', this.keypress);
// add element drag drop events
if (this.element != null) {
this.dragElement();
}
const updateRequest = new FormData();
updateRequest.append('action', 'getAllTags');
fetch('/api/tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json())
.then((result) => {
this.setState({
items: result
});
});
}
componentWillUnmount() {
// remove the appended listeners
document.removeEventListener('click', this.handleClickOutside);
document.removeEventListener('keyup', this.keypress);
}
render() {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div className={[style.popup, themeStyle.thirdbackground].join(' ')} ref={el => this.element = el}>
<div className={[style.header, themeStyle.textcolor].join(' ')}>Add a Tag to this Video:</div>
<Line/>
<div className={style.content}>
{this.state.items ?
this.state.items.map((i) => (
<Tag onclick={() => {
this.addTag(i.tag_id, i.tag_name);
}}>{i.tag_name}</Tag>
)) : null}
</div>
</div>
);
}
/**
* Alert if clicked on outside of element
*/
handleClickOutside(event) {
const domNode = ReactDom.findDOMNode(this);
if (!domNode || !domNode.contains(event.target)) {
this.props.onHide();
}
}
/**
* key event handling
* @param event keyevent
*/
keypress(event) {
// hide if escape is pressed
if (event.key === 'Escape') {
this.props.onHide();
}
}
/**
* add a new tag to this video
* @param tagid tag id to add
* @param tagname tag name to add
*/
addTag(tagid, tagname) {
console.log(this.props);
const updateRequest = new FormData();
updateRequest.append('action', 'addTag');
updateRequest.append('id', tagid);
updateRequest.append('movieid', this.props.movie_id);
fetch('/api/tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
if (result.result !== 'success') {
console.log('error occured while writing to db -- todo error handling');
console.log(result.result);
} else {
this.props.submit(tagid, tagname);
}
this.props.onHide();
}));
}
/**
* make the element drag and droppable
*/
dragElement() {
let xOld = 0, yOld = 0;
const elmnt = this.element;
elmnt.firstChild.onmousedown = dragMouseDown;
function dragMouseDown(e) {
e.preventDefault();
// get the mouse cursor position at startup:
xOld = e.clientX;
yOld = e.clientY;
document.onmouseup = closeDragElement;
// call a function whenever the cursor moves:
document.onmousemove = elementDrag;
}
function elementDrag(e) {
e.preventDefault();
// calculate the new cursor position:
const dx = xOld - e.clientX;
const dy = yOld - e.clientY;
xOld = e.clientX;
yOld = e.clientY;
// set the element's new position:
elmnt.style.top = (elmnt.offsetTop - dy) + 'px';
elmnt.style.left = (elmnt.offsetLeft - dx) + 'px';
}
function closeDragElement() {
// stop moving when mouse button is released:
document.onmouseup = null;
document.onmousemove = null;
}
}
}
export default AddTagPopup;
@@ -1,81 +0,0 @@
import React from 'react';
import {shallow} from 'enzyme';
import '@testing-library/jest-dom';
import AddTagPopup from './AddTagPopup';
describe('<AddTagPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.unmount();
});
it('test tag insertion', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.setState({
items: [{tag_id: 1, tag_name: 'test'}, {tag_id: 2, tag_name: 'ee'}]
}, () => {
expect(wrapper.find('Tag')).toHaveLength(2);
expect(wrapper.find('Tag').first().dive().text()).toBe('test');
});
});
it('test tag click', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.instance().addTag = jest.fn();
wrapper.setState({
items: [{tag_id: 1, tag_name: 'test'}]
}, () => {
wrapper.find('Tag').first().dive().simulate('click');
expect(wrapper.instance().addTag).toHaveBeenCalledTimes(1);
});
});
it('test addtag', done => {
const wrapper = shallow(<AddTagPopup/>);
global.fetch = prepareFetchApi({result: 'success'});
wrapper.setProps({
submit: jest.fn(() => {}),
onHide: jest.fn()
}, () => {
wrapper.instance().addTag(1, 'test');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
process.nextTick(() => {
expect(wrapper.instance().props.submit).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.onHide).toHaveBeenCalledTimes(1);
global.fetch.mockClear();
done();
});
});
it('test failing addTag', done => {
const wrapper = shallow(<AddTagPopup/>);
global.fetch = prepareFetchApi({result: 'fail'});
wrapper.setProps({
submit: jest.fn(() => {}),
onHide: jest.fn()
}, () => {
wrapper.instance().addTag(1, 'test');
expect(global.fetch).toHaveBeenCalledTimes(1);
});
process.nextTick(() => {
expect(wrapper.instance().props.submit).toHaveBeenCalledTimes(0);
expect(wrapper.instance().props.onHide).toHaveBeenCalledTimes(1);
global.fetch.mockClear();
done();
});
});
});
@@ -0,0 +1,8 @@
.button {
background-color: green;
border-radius: 5px;
border-width: 0;
color: white;
margin-right: 15px;
padding: 6px;
}
+32
View File
@@ -0,0 +1,32 @@
import {shallow} from 'enzyme';
import React from 'react';
import {Button} from './Button';
function prepareFetchApi(response) {
const mockJsonPromise = Promise.resolve(response);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise
});
return (jest.fn().mockImplementation(() => mockFetchPromise));
}
describe('<Button/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<Button onClick={() => {}} title='test'/>);
wrapper.unmount();
});
it('renders title', function () {
const wrapper = shallow(<Button onClick={() => {}} title='test1'/>);
expect(wrapper.text()).toBe('test1');
});
it('test onclick handling', () => {
const func = jest.fn();
const wrapper = shallow(<Button onClick={func} title='test1'/>);
wrapper.find('button').simulate('click');
expect(func).toHaveBeenCalledTimes(1);
});
});
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import style from './Button.module.css';
interface ButtonProps {
title: string | JSX.Element;
onClick?: () => void;
color?: React.CSSProperties;
}
export function Button(props: ButtonProps): JSX.Element {
return (
<button className={style.button} style={props.color} onClick={props.onClick}>
{props.title}
</button>
);
}
@@ -2,14 +2,23 @@ import React from 'react';
import style from './InfoHeaderItem.module.css';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {Spinner} from 'react-bootstrap';
import {IconDefinition} from '@fortawesome/fontawesome-common-types';
interface props {
onClick?: () => void
backColor: string
icon: IconDefinition
text: string | number
subtext: string | number
}
/**
* a component to display one of the short quickinfo tiles on dashboard
*/
class InfoHeaderItem extends React.Component {
render() {
class InfoHeaderItem extends React.Component<props> {
render(): JSX.Element {
return (
<div onClick={() => {
<div onClick={(): void => {
// call clicklistener if defined
if (this.props.onClick != null) this.props.onClick();
}} className={style.infoheaderitem} style={{backgroundColor: this.props.backColor}}>
-71
View File
@@ -1,71 +0,0 @@
import React from 'react';
import Modal from 'react-bootstrap/Modal';
import {Form} from 'react-bootstrap';
/**
* creates modal overlay to define a new Tag
*/
class NewTagPopup extends React.Component {
constructor(props, context) {
super(props, context);
this.props = props;
}
render() {
return (
<>
<Modal
show={this.props.show}
onHide={this.props.onHide}
size='lg'
aria-labelledby='contained-modal-title-vcenter'
centered>
<Modal.Header closeButton>
<Modal.Title id='contained-modal-title-vcenter'>
Create a new Tag!
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group>
<Form.Label>Tag Name:</Form.Label>
<Form.Control id='namefield' type='text' placeholder='Enter Tag name' onChange={(v) => {
this.value = v.target.value;
}}/>
<Form.Text className='text-muted'>
This Tag will automatically show up on category page.
</Form.Text>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<button className='btn btn-primary' onClick={() => {
this.storeselection();
}}>Add
</button>
</Modal.Footer>
</Modal>
</>
);
}
/**
* store the filled in form to the backend
*/
storeselection() {
const updateRequest = new FormData();
updateRequest.append('action', 'createTag');
updateRequest.append('tagname', this.value);
fetch('/api/tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json())
.then((result) => {
if (result.result !== 'success') {
console.log('error occured while writing to db -- todo error handling');
console.log(result.result);
}
this.props.onHide();
});
}
}
export default NewTagPopup;
+8 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import {shallow} from 'enzyme';
import PageTitle from './PageTitle';
import PageTitle, {Line} from './PageTitle';
describe('<Preview/>', function () {
it('renders without crashing ', function () {
@@ -29,3 +29,10 @@ describe('<Preview/>', function () {
});
});
describe('<Line/>', () => {
it('renders without crashing', function () {
const wrapper = shallow(<Line/>);
wrapper.unmount();
});
});
@@ -1,12 +1,17 @@
import React from 'react';
import style from './PageTitle.module.css';
import GlobalInfos from '../../GlobalInfos';
import GlobalInfos from '../../utils/GlobalInfos';
interface props {
title: string;
subtitle: string | number | null;
}
/**
* Component for generating PageTitle with bottom Line
*/
class PageTitle extends React.Component {
render() {
class PageTitle extends React.Component<props> {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div className={style.pageheader + ' ' + themeStyle.backgroundcolor}>
@@ -26,7 +31,7 @@ class PageTitle extends React.Component {
* use this for horizontal lines to use the current active theming
*/
export class Line extends React.Component {
render() {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<>
@@ -0,0 +1,19 @@
.newactorbutton {
background-color: green;
border-radius: 5px;
border-width: 0;
color: white;
margin-right: 15px;
margin-top: 12px;
padding: 6px;
width: 140px;
}
.searchinput {
float: left;
width: 120px;
}
.searchbar {
margin-bottom: 13px;
}
@@ -0,0 +1,89 @@
import {shallow} from 'enzyme';
import React from 'react';
import AddActorPopup from './AddActorPopup';
import {callAPI} from '../../../utils/Api';
describe('<AddActorPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<AddActorPopup/>);
wrapper.unmount();
});
it('simulate change to other page', function () {
const wrapper = shallow(<AddActorPopup/>);
expect(wrapper.find('NewActorPopupContent')).toHaveLength(0);
wrapper.find('PopupBase').props().banner.props.onClick();
// check if new content is showing
expect(wrapper.find('NewActorPopupContent')).toHaveLength(1);
});
it('hide new actor page', function () {
const wrapper = shallow(<AddActorPopup/>);
wrapper.find('PopupBase').props().banner.props.onClick();
// call onhide event listener manually
wrapper.find('NewActorPopupContent').props().onHide();
// expect other page to be hidden again
expect(wrapper.find('NewActorPopupContent')).toHaveLength(0);
});
it('test api call and insertion of actor tiles', function () {
global.callAPIMock([{id: 1, name: 'test'}, {id: 2, name: 'test2'}]);
const wrapper = shallow(<AddActorPopup/>);
expect(wrapper.find('ActorTile')).toHaveLength(2);
});
it('simulate actortile click', function () {
const func = jest.fn();
const wrapper = shallow(<AddActorPopup onHide={() => {func();}} movie_id={1}/>);
global.callAPIMock({result: 'success'});
wrapper.setState({actors: [{actor_id: 1, name: 'test'}]}, () => {
wrapper.find('ActorTile').dive().simulate('click');
expect(callAPI).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledTimes(1);
});
});
it('test failing actortile click', function () {
const func = jest.fn();
const wrapper = shallow(<AddActorPopup onHide={() => {func();}}/>);
global.callAPIMock({result: 'nosuccess'});
wrapper.setState({actors: [{actor_id: 1, name: 'test'}]}, () => {
wrapper.find('ActorTile').dive().simulate('click');
expect(callAPI).toHaveBeenCalledTimes(1);
// hide funtion should not have been called on error!
expect(func).toHaveBeenCalledTimes(0);
});
});
it('test no actor on loading', function () {
const wrapper = shallow(<AddActorPopup/>);
expect(wrapper.find('PopupBase').find('ActorTile')).toHaveLength(0);
});
it('test Enter submit if only one element left', function () {
const wrapper = shallow(<AddActorPopup/>);
callAPIMock({});
wrapper.setState({actors: [{name: 'test', actor_id: 1}]});
wrapper.find('PopupBase').props().ParentSubmit();
expect(callAPI).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,201 @@
import PopupBase from '../PopupBase';
import React from 'react';
import ActorTile from '../../ActorTile/ActorTile';
import style from './AddActorPopup.module.css';
import {NewActorPopupContent} from '../NewActorPopup/NewActorPopup';
import {APINode, callAPI} from '../../../utils/Api';
import {ActorType} from '../../../types/VideoTypes';
import {GeneralSuccess} from '../../../types/GeneralTypes';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faFilter, faTimes} from '@fortawesome/free-solid-svg-icons';
import {Button} from '../../GPElements/Button';
import {addKeyHandler, removeKeyHandler} from '../../../utils/ShortkeyHandler';
interface props {
onHide: () => void;
movie_id: number;
}
interface state {
contentDefault: boolean;
actors: ActorType[];
filter: string;
filtervisible: boolean;
}
/**
* Popup for Adding a new Actor to a Video
*/
class AddActorPopup extends React.Component<props, state> {
// filterfield anchor, needed to focus after filter btn click
private filterfield: HTMLInputElement | null | undefined;
constructor(props: props) {
super(props);
this.state = {
contentDefault: true,
actors: [],
filter: '',
filtervisible: false
};
this.tileClickHandler = this.tileClickHandler.bind(this);
this.filterSearch = this.filterSearch.bind(this);
this.parentSubmit = this.parentSubmit.bind(this);
this.keypress = this.keypress.bind(this);
}
componentWillUnmount(): void {
removeKeyHandler(this.keypress);
}
componentDidMount(): void {
addKeyHandler(this.keypress);
// fetch the available actors
this.loadActors();
}
render(): JSX.Element {
return (
<>
{/* todo render actor tiles here and add search field*/}
<PopupBase title='Add new Actor to Video' onHide={this.props.onHide} banner={
<button
className={style.newactorbutton}
onClick={(): void => {
this.setState({contentDefault: false});
}}>Create new Actor</button>} ParentSubmit={this.parentSubmit}>
{this.resolvePage()}
</PopupBase>
</>
);
}
/**
* selector for current showing popup page
* @returns {JSX.Element}
*/
resolvePage(): JSX.Element {
if (this.state.contentDefault) return (this.getContent());
else return (<NewActorPopupContent onHide={(): void => {
this.loadActors();
this.setState({contentDefault: true});
}}/>);
}
/**
* returns content for the newActor popup
* @returns {JSX.Element}
*/
getContent(): JSX.Element {
if (this.state.actors.length !== 0) {
return (
<>
<div className={style.searchbar}>
{
this.state.filtervisible ?
<>
<input className={'form-control mr-sm-2 ' + style.searchinput}
type='text' placeholder='Filter' value={this.state.filter}
onChange={(e): void => {
this.setState({filter: e.target.value});
}}
ref={(input): void => {this.filterfield = input;}}/>
<Button title={<FontAwesomeIcon style={{
verticalAlign: 'middle',
lineHeight: '130px'
}} icon={faTimes} size='1x'/>} color={{backgroundColor: 'red'}} onClick={(): void => {
this.setState({filter: '', filtervisible: false});
}}/>
</> :
<Button
title={<span>Filter <FontAwesomeIcon style={{
verticalAlign: 'middle',
lineHeight: '130px'
}} icon={faFilter} size='1x'/></span>}
color={{backgroundColor: 'cornflowerblue', color: 'white'}}
onClick={(): void => this.enableFilterField()}/>
}
</div>
{this.state.actors.filter(this.filterSearch).map((el) => (<ActorTile actor={el} onClick={this.tileClickHandler}/>))}
</>
);
} else {
return (<div>somekind of loading</div>);
}
}
/**
* event handling for ActorTile Click
*/
tileClickHandler(actor: ActorType): void {
// fetch the available actors
callAPI<GeneralSuccess>(APINode.Actor, {
action: 'addActorToVideo',
actorid: actor.actor_id,
videoid: this.props.movie_id
}, result => {
if (result.result === 'success') {
// return back to player page
this.props.onHide();
} else {
console.error('an error occured while fetching actors: ' + result);
}
});
}
/**
* load the actors from backend and set state
*/
loadActors(): void {
callAPI<ActorType[]>(APINode.Actor, {action: 'getAllActors'}, result => {
this.setState({actors: result});
});
}
/**
* enable filterfield and focus into searchbar
*/
private enableFilterField(): void {
this.setState({filtervisible: true}, () => {
// focus filterfield after state update
this.filterfield?.focus();
});
}
/**
* filter the actor array for search matches
* @param actor
*/
private filterSearch(actor: ActorType): boolean {
return actor.name.toLowerCase().includes(this.state.filter.toLowerCase());
}
/**
* handle a Popupbase parent submit action
*/
private parentSubmit(): void {
// allow submit only if one item is left in selection
const filteredList = this.state.actors.filter(this.filterSearch);
if (filteredList.length === 1) {
// simulate click if parent submit
this.tileClickHandler(filteredList[0]);
}
}
/**
* key event handling
* @param event keyevent
*/
private keypress(event: KeyboardEvent): void {
// hide if escape is pressed
if (event.key === 'f') {
this.enableFilterField();
}
}
}
export default AddActorPopup;
@@ -0,0 +1,35 @@
import React from 'react';
import {shallow} from 'enzyme';
import '@testing-library/jest-dom';
import AddTagPopup from './AddTagPopup';
describe('<AddTagPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.unmount();
});
it('test tag insertion', function () {
const wrapper = shallow(<AddTagPopup/>);
wrapper.setState({
items: [{tag_id: 1, tag_name: 'test'}, {tag_id: 2, tag_name: 'ee'}]
}, () => {
expect(wrapper.find('Tag')).toHaveLength(2);
expect(wrapper.find('Tag').first().dive().text()).toBe('test');
});
});
it('test tag click', function () {
const wrapper = shallow(<AddTagPopup submit={jest.fn()} onHide={jest.fn()}/>);
wrapper.setState({
items: [{tag_id: 1, tag_name: 'test'}]
}, () => {
wrapper.find('Tag').first().dive().simulate('click');
expect(wrapper.instance().props.submit).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.onHide).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,51 @@
import React from 'react';
import Tag from '../../Tag/Tag';
import PopupBase from '../PopupBase';
import {APINode, callAPI} from '../../../utils/Api';
import {TagType} from '../../../types/VideoTypes';
interface props {
onHide: () => void;
submit: (tagId: number, tagName: string) => void;
movie_id: number;
}
interface state {
items: TagType[];
}
/**
* component creates overlay to add a new tag to a video
*/
class AddTagPopup extends React.Component<props, state> {
constructor(props: props) {
super(props);
this.state = {items: []};
}
componentDidMount(): void {
callAPI(APINode.Tags, {action: 'getAllTags'}, (result: TagType[]) => {
this.setState({
items: result
});
});
}
render(): JSX.Element {
return (
<PopupBase title='Add a Tag to this Video:' onHide={this.props.onHide}>
{this.state.items ?
this.state.items.map((i) => (
<Tag tagInfo={i}
onclick={(): void => {
this.props.submit(i.tag_id, i.tag_name);
this.props.onHide();
}}/>
)) : null}
</PopupBase>
);
}
}
export default AddTagPopup;
@@ -0,0 +1,8 @@
.savebtn {
background-color: greenyellow;
border: 0;
border-radius: 4px;
float: right;
margin-top: 30px;
padding: 3px;
}
@@ -0,0 +1,60 @@
import React from 'react';
import {shallow} from 'enzyme';
import '@testing-library/jest-dom';
import NewActorPopup, {NewActorPopupContent} from './NewActorPopup';
import {callAPI} from '../../../utils/Api';
describe('<NewActorPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<NewActorPopup/>);
wrapper.unmount();
});
});
describe('<NewActorPopupContent/>', () => {
it('renders without crashing', function () {
const wrapper = shallow(<NewActorPopupContent/>);
wrapper.unmount();
});
it('simulate button click', function () {
global.callAPIMock({});
const func = jest.fn();
const wrapper = shallow(<NewActorPopupContent onHide={() => {func();}}/>);
// manually set typed in actorname
wrapper.instance().value = 'testactorname';
global.fetch = prepareFetchApi({});
expect(callAPI).toBeCalledTimes(0);
wrapper.find('button').simulate('click');
// fetch should have been called once now
expect(callAPI).toBeCalledTimes(1);
expect(func).toHaveBeenCalledTimes(1);
});
it('test not allowing request if textfield is empty', function () {
const wrapper = shallow(<NewActorPopupContent/>);
global.fetch = prepareFetchApi({});
expect(global.fetch).toBeCalledTimes(0);
wrapper.find('button').simulate('click');
// fetch should not be called now
expect(global.fetch).toBeCalledTimes(0);
});
it('test input change', function () {
const wrapper = shallow(<NewActorPopupContent/>);
wrapper.find('input').simulate('change', {target: {value: 'testinput'}});
expect(wrapper.instance().value).toBe('testinput');
});
});
@@ -0,0 +1,56 @@
import React from 'react';
import PopupBase from '../PopupBase';
import style from './NewActorPopup.module.css';
import {APINode, callAPI} from '../../../utils/Api';
import {GeneralSuccess} from '../../../types/GeneralTypes';
interface NewActorPopupProps {
onHide: () => void;
}
/**
* creates modal overlay to define a new Tag
*/
class NewActorPopup extends React.Component<NewActorPopupProps> {
render(): JSX.Element {
return (
<PopupBase title='Add new Tag' onHide={this.props.onHide} height='200px' width='400px'>
<NewActorPopupContent onHide={this.props.onHide}/>
</PopupBase>
);
}
}
export class NewActorPopupContent extends React.Component<NewActorPopupProps> {
value: string | undefined;
render(): JSX.Element {
return (
<>
<div>
<input type='text' placeholder='Actor Name' onChange={(v): void => {
this.value = v.target.value;
}}/></div>
<button className={style.savebtn} onClick={(): void => this.storeselection()}>Save</button>
</>
);
}
/**
* store the filled in form to the backend
*/
storeselection(): void {
// check if user typed in name
if (this.value === '' || this.value === undefined) return;
callAPI(APINode.Actor, {action: 'createActor', actorname: this.value}, (result: GeneralSuccess) => {
if (result.result !== 'success') {
console.log('error occured while writing to db -- todo error handling');
console.log(result.result);
}
this.props.onHide();
});
}
}
export default NewActorPopup;
@@ -0,0 +1,8 @@
.savebtn {
background-color: greenyellow;
border: 0;
border-radius: 4px;
float: right;
margin-top: 30px;
padding: 3px;
}
@@ -11,12 +11,7 @@ describe('<NewTagPopup/>', function () {
});
it('test storeseletion click event', done => {
const mockSuccessResponse = {};
const mockJsonPromise = Promise.resolve(mockSuccessResponse);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise
});
global.fetch = jest.fn().mockImplementation(() => mockFetchPromise);
global.fetch = prepareFetchApi({});
const func = jest.fn();
@@ -27,7 +22,9 @@ describe('<NewTagPopup/>', function () {
}
});
wrapper.find('ModalFooter').find('button').simulate('click');
wrapper.instance().value = 'testvalue';
wrapper.find('button').simulate('click');
expect(global.fetch).toHaveBeenCalledTimes(1);
process.nextTick(() => {
@@ -38,4 +35,12 @@ describe('<NewTagPopup/>', function () {
done();
});
});
it('simulate textfield change', function () {
const wrapper = shallow(<NewTagPopup/>);
wrapper.find('input').simulate('change', {target: {value: 'testvalue'}});
expect(wrapper.instance().value).toBe('testvalue');
});
});
@@ -0,0 +1,42 @@
import React from 'react';
import PopupBase from '../PopupBase';
import style from './NewTagPopup.module.css';
import {APINode, callAPI} from '../../../utils/Api';
import {GeneralSuccess} from '../../../types/GeneralTypes';
interface props {
onHide: () => void
}
/**
* creates modal overlay to define a new Tag
*/
class NewTagPopup extends React.Component<props> {
private value: string = '';
render(): JSX.Element {
return (
<PopupBase title='Add new Tag' onHide={this.props.onHide} height='200px' width='400px' ParentSubmit={(): void => this.storeselection()}>
<div><input type='text' placeholder='Tagname' onChange={(v): void => {
this.value = v.target.value;
}}/></div>
<button className={style.savebtn} onClick={(): void => this.storeselection()}>Save</button>
</PopupBase>
);
}
/**
* store the filled in form to the backend
*/
storeselection(): void {
callAPI(APINode.Tags, {action: 'createTag', tagname: this.value}, (result: GeneralSuccess) => {
if (result.result !== 'success') {
console.log('error occured while writing to db -- todo error handling');
console.log(result.result);
}
this.props.onHide();
});
}
}
export default NewTagPopup;
@@ -0,0 +1,29 @@
import {shallow} from 'enzyme';
import React from 'react';
import {NoBackendConnectionPopup} from './NoBackendConnectionPopup';
import {getBackendDomain} from '../../../utils/Api';
describe('<NoBackendConnectionPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<NoBackendConnectionPopup onHide={() => {}}/>);
wrapper.unmount();
});
it('hides on refresh click', function () {
const func = jest.fn();
const wrapper = shallow(<NoBackendConnectionPopup onHide={func}/>);
expect(func).toBeCalledTimes(0);
wrapper.find('button').simulate('click');
expect(func).toBeCalledTimes(1);
});
it('simulate change of textfield', function () {
const wrapper = shallow(<NoBackendConnectionPopup onHide={() => {}}/>);
wrapper.find('input').simulate('change', {target: {value: 'testvalue'}});
expect(getBackendDomain()).toBe('testvalue');
});
});
@@ -0,0 +1,20 @@
import React from 'react';
import PopupBase from '../PopupBase';
import style from '../NewActorPopup/NewActorPopup.module.css';
import {setCustomBackendDomain} from '../../../utils/Api';
interface NBCProps {
onHide: (_: void) => void
}
export function NoBackendConnectionPopup(props: NBCProps): JSX.Element {
return (
<PopupBase title='No connection to backend API!' onHide={props.onHide} height='200px' width='600px'>
<div>
<input type='text' placeholder='http://192.168.0.2' onChange={(v): void => {
setCustomBackendDomain(v.target.value);
}}/></div>
<button className={style.savebtn} onClick={(): void => props.onHide()}>Refresh</button>
</PopupBase>
);
}
+45
View File
@@ -0,0 +1,45 @@
.popup {
border: 3px #3574fe solid;
border-radius: 18px;
height: fit-content;
left: 20%;
min-height: 80%;
opacity: 0.95;
position: absolute;
top: 10%;
width: 60%;
z-index: 2;
}
.header {
cursor: move;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
}
.title {
float: left;
font-size: x-large;
margin-left: 15px;
margin-top: 10px;
opacity: 1;
width: 60%;
}
.banner {
display: flex;
flex-direction: row;
float: left;
justify-content: flex-end;
width: 40%;
}
.content {
margin-left: 20px;
margin-right: 20px;
margin-top: 10px;
opacity: 1;
overflow: auto;
}
+41
View File
@@ -0,0 +1,41 @@
import {shallow} from 'enzyme';
import React from 'react';
import PopupBase from './PopupBase';
describe('<PopupBase/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<PopupBase/>);
wrapper.unmount();
});
let events;
function mockKeyPress() {
events = [];
document.addEventListener = jest.fn((event, cb) => {
events[event] = cb;
});
}
it('simulate keypress', function () {
mockKeyPress();
const func = jest.fn();
shallow(<PopupBase onHide={() => func()}/>);
// trigger the keypress event
events.keyup({key: 'Escape'});
expect(func).toBeCalledTimes(1);
});
it('test an Enter sumit', function () {
mockKeyPress();
const func = jest.fn();
shallow(<PopupBase ParentSubmit={() => func()}/>);
// trigger the keypress event
events.keyup({key: 'Enter'});
expect(func).toBeCalledTimes(1);
});
});
+141
View File
@@ -0,0 +1,141 @@
import GlobalInfos from '../../utils/GlobalInfos';
import style from './PopupBase.module.css';
import {Line} from '../PageTitle/PageTitle';
import React, {RefObject} from 'react';
import {addKeyHandler, removeKeyHandler} from '../../utils/ShortkeyHandler';
interface props {
width?: string;
height?: string;
banner?: JSX.Element;
title: string;
onHide: () => void;
ParentSubmit?: () => void;
}
/**
* wrapper class for generic types of popups
*/
class PopupBase extends React.Component<props> {
private wrapperRef: RefObject<HTMLDivElement>;
private framedimensions: { minHeight: string | undefined; width: string | undefined; height: string | undefined };
constructor(props: props) {
super(props);
this.state = {items: []};
this.wrapperRef = React.createRef();
this.handleClickOutside = this.handleClickOutside.bind(this);
this.keypress = this.keypress.bind(this);
// parse style props
this.framedimensions = {
width: (this.props.width ? this.props.width : undefined),
height: (this.props.height ? this.props.height : undefined),
minHeight: (this.props.height ? this.props.height : undefined)
};
}
componentDidMount(): void {
document.addEventListener('mousedown', this.handleClickOutside);
addKeyHandler(this.keypress);
// add element drag drop events
if (this.wrapperRef != null) {
this.dragElement();
}
}
componentWillUnmount(): void {
// remove the appended listeners
document.removeEventListener('mousedown', this.handleClickOutside);
removeKeyHandler(this.keypress);
}
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div style={this.framedimensions} className={[style.popup, themeStyle.thirdbackground].join(' ')} ref={this.wrapperRef}>
<div className={style.header}>
<div className={[style.title, themeStyle.textcolor].join(' ')}>{this.props.title}</div>
<div className={style.banner}>{this.props.banner}</div>
</div>
<Line/>
<div className={style.content}>
{this.props.children}
</div>
</div>
);
}
/**
* Alert if clicked on outside of element
*/
handleClickOutside(event: MouseEvent): void {
if (this.wrapperRef && this.wrapperRef.current && !this.wrapperRef.current.contains(event.target as Node)) {
this.props.onHide();
}
}
/**
* key event handling
* @param event keyevent
*/
keypress(event: KeyboardEvent): void {
// hide if escape is pressed
if (event.key === 'Escape') {
this.props.onHide();
} else if (event.key === 'Enter') {
// call a parentsubmit if defined
if (this.props.ParentSubmit) this.props.ParentSubmit();
}
}
/**
* make the element drag and droppable
*/
dragElement(): void {
let xOld = 0, yOld = 0;
const elmnt = this.wrapperRef.current;
if (elmnt === null) return;
if (elmnt.firstChild === null) return;
(elmnt.firstChild as HTMLDivElement).onmousedown = dragMouseDown;
function dragMouseDown(e: MouseEvent): void {
e.preventDefault();
// get the mouse cursor position at startup:
xOld = e.clientX;
yOld = e.clientY;
document.onmouseup = closeDragElement;
// call a function whenever the cursor moves:
document.onmousemove = elementDrag;
}
function elementDrag(e: MouseEvent): void {
e.preventDefault();
// calculate the new cursor position:
const dx = xOld - e.clientX;
const dy = yOld - e.clientY;
xOld = e.clientX;
yOld = e.clientY;
// set the element's new position:
if (elmnt === null) return;
elmnt.style.top = (elmnt.offsetTop - dy) + 'px';
elmnt.style.left = (elmnt.offsetLeft - dx) + 'px';
}
function closeDragElement(): void {
// stop moving when mouse button is released:
document.onmouseup = null;
document.onmousemove = null;
}
}
}
export default PopupBase;
@@ -0,0 +1,28 @@
import {shallow} from 'enzyme';
import React from 'react';
import SubmitPopup from './SubmitPopup';
describe('<SubmitPopup/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<SubmitPopup/>);
wrapper.unmount();
});
it('test submit click', function () {
const func = jest.fn();
const wrapper = shallow(<SubmitPopup submit={() => func()}/>);
wrapper.find('Button').findWhere(p => p.props().title === 'Submit').simulate('click');
expect(func).toHaveBeenCalledTimes(1);
});
it('test cancel click', function () {
const func = jest.fn();
const wrapper = shallow(<SubmitPopup onHide={() => func()}/>);
wrapper.find('Button').findWhere(p => p.props().title === 'Cancel').simulate('click');
expect(func).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,18 @@
import React from 'react';
import PopupBase from '../PopupBase';
import {Button} from '../../GPElements/Button';
interface props {
onHide: (_: void) => void;
submit: (_: void) => void;
}
export default function SubmitPopup(props: props): JSX.Element {
return (
<PopupBase title='Are you sure?' onHide={props.onHide} height='160px' width='300px'>
<Button title='Submit' color={{backgroundColor: 'green'}} onClick={(): void => props.submit()}/>
<Button title='Cancel' color={{backgroundColor: 'red'}} onClick={(): void => props.onHide()}/>
</PopupBase>
);
}
-99
View File
@@ -1,99 +0,0 @@
import React from 'react';
import style from './Preview.module.css';
import Player from '../../pages/Player/Player';
import {Spinner} from 'react-bootstrap';
import GlobalInfos from '../../GlobalInfos';
/**
* Component for single preview tile
* floating side by side
*/
class Preview extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
previewpicture: null,
name: null
};
}
componentDidMount() {
this.setState({
previewpicture: null,
name: this.props.name
});
const updateRequest = new FormData();
updateRequest.append('action', 'readThumbnail');
updateRequest.append('movieid', this.props.movie_id);
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.text()
.then((result) => {
this.setState({
previewpicture: result
});
}));
}
render() {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div className={style.videopreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}
onClick={() => this.itemClick()}>
<div className={style.previewtitle + ' ' + themeStyle.lighttextcolor}>{this.state.name}</div>
<div className={style.previewpic}>
{this.state.previewpicture !== null ?
<img className={style.previewimage}
src={this.state.previewpicture}
alt='Pic loading.'/> :
<span className={style.loadAnimation}><Spinner animation='border'/></span>}
</div>
<div className={style.previewbottom}>
</div>
</div>
);
}
/**
* handle the click event of a tile
*/
itemClick() {
console.log('item clicked!' + this.state.name);
this.props.viewbinding.changeRootElement(
<Player
viewbinding={this.props.viewbinding}
movie_id={this.props.movie_id}/>);
}
}
/**
* Component for a Tag-name tile (used in category page)
*/
export class TagPreview extends React.Component {
render() {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div
className={style.videopreview + ' ' + style.tagpreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}
onClick={() => this.itemClick()}>
<div className={style.tagpreviewtitle + ' ' + themeStyle.lighttextcolor}>
{this.props.name}
</div>
</div>
);
}
/**
* handle the click event of a Tag tile
*/
itemClick() {
this.props.categorybinding(this.props.name);
}
}
export default Preview;
+79
View File
@@ -0,0 +1,79 @@
import React from 'react';
import style from './Preview.module.css';
import {Spinner} from 'react-bootstrap';
import {Link} from 'react-router-dom';
import GlobalInfos from '../../utils/GlobalInfos';
import {APINode, callAPIPlain} from '../../utils/Api';
interface PreviewProps {
name: string;
movie_id: number;
}
interface PreviewState {
previewpicture: string | null;
}
/**
* Component for single preview tile
* floating side by side
*/
class Preview extends React.Component<PreviewProps, PreviewState> {
constructor(props: PreviewProps) {
super(props);
this.state = {
previewpicture: null
};
}
componentDidMount(): void {
callAPIPlain(APINode.Video, {action: 'readThumbnail', movieid: this.props.movie_id}, (result) => {
this.setState({
previewpicture: result
});
});
}
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<Link to={'/player/' + this.props.movie_id}>
<div className={style.videopreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}>
<div className={style.previewtitle + ' ' + themeStyle.lighttextcolor}>{this.props.name}</div>
<div className={style.previewpic}>
{this.state.previewpicture !== null ?
<img className={style.previewimage}
src={this.state.previewpicture}
alt='Pic loading.'/> :
<span className={style.loadAnimation}><Spinner animation='border'/></span>}
</div>
<div className={style.previewbottom}>
</div>
</div>
</Link>
);
}
}
/**
* Component for a Tag-name tile (used in category page)
*/
export class TagPreview extends React.Component<{ name: string }> {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div
className={style.videopreview + ' ' + style.tagpreview + ' ' + themeStyle.secbackground + ' ' + themeStyle.preview}>
<div className={style.tagpreviewtitle + ' ' + themeStyle.lighttextcolor}>
{this.props.name}
</div>
</div>
);
}
}
export default Preview;
+5 -47
View File
@@ -5,35 +5,10 @@ import Preview, {TagPreview} from './Preview';
describe('<Preview/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<Preview/>);
const wrapper = shallow(<Preview movie_id={1}/>);
wrapper.unmount();
});
// check if preview title renders correctly
it('renders title', () => {
const wrapper = shallow(<Preview name='test'/>);
expect(wrapper.find('.previewtitle').text()).toBe('test');
});
it('click event triggered', () => {
const func = jest.fn();
const wrapper = shallow(<Preview/>);
wrapper.setProps({
viewbinding: {
changeRootElement: () => {
func();
}
}
});
wrapper.find('.videopreview').simulate('click');
//callback to open player should have called
expect(func).toHaveBeenCalledTimes(1);
});
it('picture rendered correctly', done => {
const mockSuccessResponse = 'testsrc';
const mockJsonPromise = Promise.resolve(mockSuccessResponse);
@@ -42,7 +17,7 @@ describe('<Preview/>', function () {
});
global.fetch = jest.fn().mockImplementation(() => mockFetchPromise);
const wrapper = shallow(<Preview/>);
const wrapper = shallow(<Preview name='test' movie_id={1}/>);
// now called 1 times
expect(global.fetch).toHaveBeenCalledTimes(1);
@@ -50,6 +25,8 @@ describe('<Preview/>', function () {
process.nextTick(() => {
// received picture should be rendered into wrapper
expect(wrapper.find('.previewimage').props().src).not.toBeNull();
// check if preview title renders correctly
expect(wrapper.find('.previewtitle').text()).toBe('test');
global.fetch.mockClear();
done();
@@ -58,7 +35,7 @@ describe('<Preview/>', function () {
});
it('spinner loads correctly', function () {
const wrapper = shallow(<Preview/>);
const wrapper = shallow(<Preview movie_id={1}/>);
// expect load animation to be visible
expect(wrapper.find('.loadAnimation')).toHaveLength(1);
@@ -76,24 +53,5 @@ describe('<TagPreview/>', function () {
const wrapper = shallow(<TagPreview name='test'/>);
expect(wrapper.find('.tagpreviewtitle').text()).toBe('test');
});
it('click event triggered', function () {
const func = jest.fn();
const wrapper = shallow(<TagPreview/>);
wrapper.setProps({
categorybinding: () => {
func();
}
});
// first call of fetch is getting of available tags
expect(func).toHaveBeenCalledTimes(0);
wrapper.find('.videopreview').simulate('click');
// now called 1 times
expect(func).toHaveBeenCalledTimes(1);
});
});
+3
View File
@@ -1,6 +1,9 @@
.sideinfo {
border: 2px #3574fe solid;
border-radius: 20px;
}
.sideinfogeometry {
float: left;
margin-left: 15px;
margin-top: 25px;
@@ -1,14 +1,21 @@
import React from 'react';
import style from './SideBar.module.css';
import GlobalInfos from '../../GlobalInfos';
import GlobalInfos from '../../utils/GlobalInfos';
interface SideBarProps {
hiddenFrame?: boolean;
width?: string;
}
/**
* component for sidebar-info
*/
class SideBar extends React.Component {
render() {
class SideBar extends React.Component<SideBarProps> {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (<div className={style.sideinfo + ' ' + themeStyle.secbackground}>
const classnn = style.sideinfogeometry + ' ' + (this.props.hiddenFrame === undefined ? style.sideinfo + ' ' + themeStyle.secbackground : '');
return (<div className={classnn} style={{width: this.props.width}}>
{this.props.children}
</div>);
}
@@ -18,7 +25,7 @@ class SideBar extends React.Component {
* The title of the sidebar
*/
export class SideBarTitle extends React.Component {
render() {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div className={style.sidebartitle + ' ' + themeStyle.subtextcolor}>{this.props.children}</div>
@@ -30,7 +37,7 @@ export class SideBarTitle extends React.Component {
* An item of the sidebar
*/
export class SideBarItem extends React.Component {
render() {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<div
-36
View File
@@ -1,36 +0,0 @@
import React from 'react';
import styles from './Tag.module.css';
import CategoryPage from '../../pages/CategoryPage/CategoryPage';
/**
* A Component representing a single Category tag
*/
class Tag extends React.Component {
render() {
return (
<button className={styles.tagbtn} onClick={() => this.TagClick()}
data-testid='Test-Tag'>{this.props.children}</button>
);
}
/**
* click handling for a Tag
*/
TagClick() {
if (this.props.onclick) {
this.props.onclick();
return;
}
const tag = this.props.children.toString().toLowerCase();
// call callback functin to switch to category page with specified tag
this.props.viewbinding.changeRootElement(
<CategoryPage
category={tag}
viewbinding={this.props.viewbinding}/>);
}
}
export default Tag;
+3 -19
View File
@@ -6,36 +6,20 @@ import {shallow} from 'enzyme';
describe('<Tag/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<Tag>test</Tag>);
const wrapper = shallow(<Tag tagInfo={{tag_name: 'testname', tag_id: 1}}/>);
wrapper.unmount();
});
it('renders childs correctly', function () {
const wrapper = shallow(<Tag>test</Tag>);
const wrapper = shallow(<Tag tagInfo={{tag_name: 'test', tag_id: 1}}/>);
expect(wrapper.children().text()).toBe('test');
});
it('click event triggered and setvideo callback called', function () {
global.fetch = global.prepareFetchApi({});
const func = jest.fn();
const elem = {
changeRootElement: () => func()
};
const wrapper = shallow(<Tag
viewbinding={elem}>test</Tag>);
expect(func).toBeCalledTimes(0);
wrapper.simulate('click');
expect(func).toBeCalledTimes(1);
});
it('test custom onclick function', function () {
const func = jest.fn();
const wrapper = shallow(<Tag
tagInfo={{tag_name: 'test', tag_id: 1}}
onclick={() => {func();}}>test</Tag>);
expect(func).toBeCalledTimes(0);
+47
View File
@@ -0,0 +1,47 @@
import React from 'react';
import styles from './Tag.module.css';
import {Link} from 'react-router-dom';
import {TagType} from '../../types/VideoTypes';
interface props {
onclick?: (_: string) => void
tagInfo: TagType
}
/**
* A Component representing a single Category tag
*/
class Tag extends React.Component<props> {
render(): JSX.Element {
if (this.props.onclick) {
return this.renderButton();
} else {
return (
<Link to={'/categories/' + this.props.tagInfo.tag_id}>
{this.renderButton()}
</Link>
);
}
}
renderButton(): JSX.Element {
return (
<button className={styles.tagbtn} onClick={(): void => this.TagClick()}
data-testid='Test-Tag'>{this.props.tagInfo.tag_name}</button>
);
}
/**
* click handling for a Tag
*/
TagClick(): void {
if (this.props.onclick) {
// call custom onclick handling
this.props.onclick(this.props.tagInfo.tag_name); // todo check if param is neccessary
return;
}
}
}
export default Tag;
@@ -1,41 +1,48 @@
import React from 'react';
import Preview from '../Preview/Preview';
import style from './VideoContainer.module.css';
import {VideoTypes} from '../../types/ApiTypes';
interface props {
data: VideoTypes.VideoUnloadedType[]
}
interface state {
loadeditems: VideoTypes.VideoUnloadedType[];
selectionnr: number;
}
/**
* A videocontainer storing lots of Preview elements
* includes scroll handling and loading of preview infos
*/
class VideoContainer extends React.Component {
class VideoContainer extends React.Component<props, state> {
// stores current index of loaded elements
loadindex = 0;
loadindex: number = 0;
constructor(props, context) {
super(props, context);
this.data = props.data;
constructor(props: props) {
super(props);
this.state = {
loadeditems: [],
selectionnr: null
selectionnr: 0
};
}
componentDidMount() {
componentDidMount(): void {
document.addEventListener('scroll', this.trackScrolling);
this.loadPreviewBlock(16);
}
render() {
render(): JSX.Element {
return (
<div className={style.maincontent}>
{this.state.loadeditems.map(elem => (
<Preview
key={elem.movie_id}
name={elem.movie_name}
movie_id={elem.movie_id}
viewbinding={this.props.viewbinding}/>
movie_id={elem.movie_id}/>
))}
{/*todo css for no items to show*/}
{this.state.loadeditems.length === 0 ?
@@ -45,7 +52,7 @@ class VideoContainer extends React.Component {
);
}
componentWillUnmount() {
componentWillUnmount(): void {
this.setState({});
// unbind scroll listener when unmounting component
document.removeEventListener('scroll', this.trackScrolling);
@@ -55,13 +62,13 @@ class VideoContainer extends React.Component {
* load previews to the container
* @param nr number of previews to load
*/
loadPreviewBlock(nr) {
loadPreviewBlock(nr: number): void {
console.log('loadpreviewblock called ...');
let ret = [];
for (let i = 0; i < nr; i++) {
// only add if not end
if (this.data.length > this.loadindex + i) {
ret.push(this.data[this.loadindex + i]);
if (this.props.data.length > this.loadindex + i) {
ret.push(this.props.data[this.loadindex + i]);
}
}
@@ -79,7 +86,7 @@ class VideoContainer extends React.Component {
/**
* scroll event handler -> load new previews if on bottom
*/
trackScrolling = () => {
trackScrolling = (): void => {
// comparison if current scroll position is on bottom --> 200 is bottom offset to trigger load
if (window.innerHeight + document.documentElement.scrollTop + 200 >= document.documentElement.offsetHeight) {
this.loadPreviewBlock(8);
+3
View File
@@ -2,6 +2,9 @@ import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
// don't allow console logs within production env
global.console.log = process.env.NODE_ENV !== 'development' ? (s: string | number | boolean): void => {} : global.console.log;
ReactDOM.render(
<React.StrictMode>
<App/>
@@ -0,0 +1,6 @@
.container {
float: left;
margin-right: 20px;
padding-left: 20px;
width: 70%;
}
@@ -0,0 +1,43 @@
import {shallow} from 'enzyme';
import React from 'react';
import ActorOverviewPage from './ActorOverviewPage';
describe('<ActorOverviewPage/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<ActorOverviewPage/>);
wrapper.unmount();
});
it('test inerstion of actor tiles', function () {
const wrapper = shallow(<ActorOverviewPage/>);
wrapper.setState({
actors: [{
thumbnail: '',
name: 'testname',
actor_id: 42
}]
});
expect(wrapper.find('ActorTile')).toHaveLength(1);
});
it('test newtagpopup visibility', function () {
const wrapper = shallow(<ActorOverviewPage/>);
expect(wrapper.find('NewActorPopup')).toHaveLength(0);
wrapper.find('SideBar').find('Button').simulate('click');
expect(wrapper.find('NewActorPopup')).toHaveLength(1);
});
it('test popup hiding', function () {
const wrapper = shallow(<ActorOverviewPage/>);
wrapper.setState({NActorPopupVisible: true});
wrapper.find('NewActorPopup').props().onHide();
expect(wrapper.find('NewActorPopup')).toHaveLength(0);
});
});
@@ -0,0 +1,59 @@
import React from 'react';
import {APINode, callAPI} from '../../utils/Api';
import {ActorType} from '../../types/VideoTypes';
import ActorTile from '../../elements/ActorTile/ActorTile';
import PageTitle from '../../elements/PageTitle/PageTitle';
import SideBar from '../../elements/SideBar/SideBar';
import style from './ActorOverviewPage.module.css';
import {Button} from '../../elements/GPElements/Button';
import NewActorPopup from '../../elements/Popups/NewActorPopup/NewActorPopup';
interface props {
}
interface state {
actors: ActorType[];
NActorPopupVisible: boolean
}
class ActorOverviewPage extends React.Component<props, state> {
constructor(props: props) {
super(props);
this.state = {
actors: [],
NActorPopupVisible: false
};
}
componentDidMount(): void {
this.fetchAvailableActors();
}
render(): JSX.Element {
return (
<>
<PageTitle title='Actors' subtitle={this.state.actors.length + ' Actors'}/>
<SideBar>
<Button title='Add Actor' onClick={(): void => this.setState({NActorPopupVisible: true})}/>
</SideBar>
<div className={style.container}>
{this.state.actors.map((el) => (<ActorTile key={el.actor_id} actor={el}/>))}
</div>
{this.state.NActorPopupVisible ?
<NewActorPopup onHide={(): void => {
this.setState({NActorPopupVisible: false});
this.fetchAvailableActors(); // refetch actors
}}/> : null}
</>
);
}
fetchAvailableActors(): void {
callAPI<ActorType[]>(APINode.Actor, {action: 'getAllActors'}, result => {
this.setState({actors: result});
});
}
}
export default ActorOverviewPage;
+9
View File
@@ -0,0 +1,9 @@
.pic {
margin-bottom: 25px;
text-align: center;
}
.overviewbutton {
float: right;
margin-top: 25px;
}
+27
View File
@@ -0,0 +1,27 @@
import {shallow} from 'enzyme';
import React from 'react';
import {ActorPage} from './ActorPage';
describe('<ActorPage/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<ActorPage match={{params: {id: 10}}}/>);
wrapper.unmount();
});
it('fetch infos', function () {
callAPIMock({
videos: [{
movie_id: 0,
movie_name: 'test'
}], info: {
thumbnail: '',
name: '',
actor_id: 0
}
});
const wrapper = shallow(<ActorPage match={{params: {id: 10}}}/>);
expect(wrapper.find('VideoContainer')).toHaveLength(1);
});
});
+81
View File
@@ -0,0 +1,81 @@
import React from 'react';
import PageTitle from '../../elements/PageTitle/PageTitle';
import SideBar, {SideBarTitle} from '../../elements/SideBar/SideBar';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faUser} from '@fortawesome/free-solid-svg-icons';
import style from './ActorPage.module.css';
import VideoContainer from '../../elements/VideoContainer/VideoContainer';
import {APINode, callAPI} from '../../utils/Api';
import {ActorType} from '../../types/VideoTypes';
import {Link, withRouter} from 'react-router-dom';
import {RouteComponentProps} from 'react-router';
import {Button} from '../../elements/GPElements/Button';
import {ActorTypes, VideoTypes} from '../../types/ApiTypes';
interface state {
data: VideoTypes.VideoUnloadedType[],
actor: ActorType
}
/**
* empty default props with id in url
*/
interface props extends RouteComponentProps<{ id: string }> {
}
/**
* info page about a specific actor and a list of all its videos
*/
export class ActorPage extends React.Component<props, state> {
constructor(props: props) {
super(props);
this.state = {data: [], actor: {actor_id: 0, name: '', thumbnail: ''}};
}
render(): JSX.Element {
return (
<>
<PageTitle title={this.state.actor.name} subtitle={this.state.data ? this.state.data.length + ' videos' : null}>
<span className={style.overviewbutton}>
<Link to='/actors'>
<Button onClick={(): void => {}} title='Go to Actor overview'/>
</Link>
</span>
</PageTitle>
<SideBar>
<div className={style.pic}>
<FontAwesomeIcon style={{color: 'white'}} icon={faUser} size='10x'/>
</div>
<SideBarTitle>Attention: This is an early preview!</SideBarTitle>
</SideBar>
{this.state.data.length !== 0 ?
<VideoContainer
data={this.state.data}/> :
<div>No Data found!</div>}
</>
);
}
componentDidMount(): void {
this.getActorInfo();
}
/**
* request more actor info from backend
*/
getActorInfo(): void {
callAPI(APINode.Actor, {
action: 'getActorInfo',
actorid: this.props.match.params.id
}, (result: ActorTypes.videofetchresult) => {
this.setState({
data: result.videos ? result.videos : [],
actor: result.info
});
});
}
}
export default withRouter(ActorPage);
-178
View File
@@ -1,178 +0,0 @@
import React from 'react';
import SideBar, {SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag';
import videocontainerstyle from '../../elements/VideoContainer/VideoContainer.module.css';
import {TagPreview} from '../../elements/Preview/Preview';
import NewTagPopup from '../../elements/NewTagPopup/NewTagPopup';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
import VideoContainer from '../../elements/VideoContainer/VideoContainer';
/**
* Component for Category Page
* Contains a Tag Overview and loads specific Tag videos in VideoContainer
*/
class CategoryPage extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
loadedtags: [],
selected: null
};
}
componentDidMount() {
// check if predefined category is set
if (this.props.category) {
this.fetchVideoData(this.props.category);
} else {
this.loadTags();
}
}
/**
* render the Title and SideBar component for the Category page
* @returns {JSX.Element} corresponding jsx element for Title and Sidebar
*/
renderSideBarATitle() {
return (
<>
<PageTitle
title='Categories'
subtitle={!this.state.selected ? this.state.loadedtags.length + ' different Tags' : this.state.selected}/>
<SideBar>
<SideBarTitle>Default Tags:</SideBarTitle>
<Tag viewbinding={{
changeRootElement: (e) => {
this.loadTag(e.props.category);
}
}}>All</Tag>
<Tag viewbinding={{
changeRootElement: (e) => {
this.loadTag(e.props.category);
}
}}>FullHd</Tag>
<Tag viewbinding={{
changeRootElement: (e) => {
this.loadTag(e.props.category);
}
}}>LowQuality</Tag>
<Tag viewbinding={{
changeRootElement: (e) => {
this.loadTag(e.props.category);
}
}}>HD</Tag>
<Line/>
<button data-testid='btnaddtag' className='btn btn-success' onClick={() => {
this.setState({popupvisible: true});
}}>Add a new Tag!
</button>
</SideBar></>
);
}
render() {
return (
<>
{this.renderSideBarATitle()}
{this.state.selected ?
<>
{this.videodata ?
<VideoContainer
data={this.videodata}
viewbinding={this.props.viewbinding}/> : null}
<button data-testid='backbtn' className='btn btn-success'
onClick={this.loadCategoryPageDefault}>Back
</button>
</> :
<div className={videocontainerstyle.maincontent}>
{this.state.loadedtags ?
this.state.loadedtags.map((m) => (
<TagPreview
key={m.tag_name}
name={m.tag_name}
tag_id={m.tag_id}
viewbinding={this.props.viewbinding}
categorybinding={this.loadTag}/>
)) :
'loading'}
</div>
}
{this.state.popupvisible ?
<NewTagPopup show={this.state.popupvisible}
onHide={() => {
this.setState({popupvisible: false});
this.loadTags();
}}/> :
null
}
</>
);
}
/**
* load a specific tag into a new previewcontainer
* @param tagname
*/
loadTag = (tagname) => {
this.fetchVideoData(tagname);
};
/**
* fetch data for a specific tag from backend
* @param tag tagname
*/
fetchVideoData(tag) {
console.log(tag);
const updateRequest = new FormData();
updateRequest.append('action', 'getMovies');
updateRequest.append('tag', tag);
console.log('fetching data');
// fetch all videos available
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
this.videodata = result;
this.setState({selected: null}); // needed to trigger the state reload correctly
this.setState({selected: tag});
}))
.catch(() => {
console.log('no connection to backend');
});
}
/**
* go back to the default category overview
*/
loadCategoryPageDefault = () => {
this.setState({selected: null});
this.loadTags();
};
/**
* load all available tags from db.
*/
loadTags() {
const updateRequest = new FormData();
updateRequest.append('action', 'getAllTags');
// fetch all videos available
fetch('/api/tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
this.setState({loadedtags: result});
}))
.catch(() => {
console.log('no connection to backend');
});
}
}
export default CategoryPage;
+1 -113
View File
@@ -1,4 +1,4 @@
import {mount, shallow} from 'enzyme';
import {shallow} from 'enzyme';
import React from 'react';
import CategoryPage from './CategoryPage';
@@ -7,116 +7,4 @@ describe('<CategoryPage/>', function () {
const wrapper = shallow(<CategoryPage/>);
wrapper.unmount();
});
it('test tag fetch call', done => {
global.fetch = global.prepareFetchApi(['first', 'second']);
const wrapper = shallow(<CategoryPage/>);
expect(global.fetch).toHaveBeenCalledTimes(1);
process.nextTick(() => {
//callback to close window should have called
expect(wrapper.state().loadedtags.length).toBe(2);
global.fetch.mockClear();
done();
});
});
it('test errored fetch call', done => {
global.fetch = global.prepareFetchApi({});
let message;
global.console.log = jest.fn((m) => {
message = m;
});
shallow(<CategoryPage/>);
expect(global.fetch).toHaveBeenCalledTimes(1);
process.nextTick(() => {
//callback to close window should have called
expect(message).toBe('no connection to backend');
global.fetch.mockClear();
done();
});
});
it('test new tag popup', function () {
const wrapper = mount(<CategoryPage/>);
expect(wrapper.find('NewTagPopup')).toHaveLength(0);
wrapper.find('[data-testid="btnaddtag"]').simulate('click');
// newtagpopup should be showing now
expect(wrapper.find('NewTagPopup')).toHaveLength(1);
// click close button in modal
wrapper.find('.modal-header').find('button').simulate('click');
expect(wrapper.find('NewTagPopup')).toHaveLength(0);
});
it('test setpage callback', done => {
global.fetch = global.prepareFetchApi([{}, {}]);
const wrapper = mount(<CategoryPage/>);
wrapper.setState({
loadedtags: [
{
tag_name: 'testname',
tag_id: 42
}
]
});
wrapper.find('TagPreview').find('div').first().simulate('click');
process.nextTick(() => {
// expect callback to have loaded correct tag
expect(wrapper.state().selected).toBe('testname');
global.fetch.mockClear();
done();
});
});
it('test back to category view callback', function () {
const wrapper = shallow(<CategoryPage/>);
wrapper.setState({
selected: 'test'
});
expect(wrapper.state().selected).not.toBeNull();
wrapper.find('[data-testid="backbtn"]').simulate('click');
expect(wrapper.state().selected).toBeNull();
});
it('load categorypage with predefined tag', function () {
const func = jest.fn();
CategoryPage.prototype.fetchVideoData = func;
shallow(<CategoryPage category='fullhd'/>);
expect(func).toBeCalledTimes(1);
});
it('test sidebar tag clicks', function () {
const func = jest.fn();
const wrapper = mount(<CategoryPage category='fullhd'/>);
wrapper.instance().loadTag = func;
console.log(wrapper.debug());
expect(func).toBeCalledTimes(0);
wrapper.find('SideBar').find('Tag').forEach(e => {
e.simulate('click');
});
expect(func).toBeCalledTimes(4);
});
});
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import {Route, Switch} from 'react-router-dom';
import {CategoryViewWR} from './CategoryView';
import TagView from './TagView';
/**
* Component for Category Page
* Contains a Tag Overview and loads specific Tag videos in VideoContainer
*/
class CategoryPage extends React.Component {
render(): JSX.Element {
return (
<Switch>
<Route path='/categories/:id'>
<CategoryViewWR/>
</Route>
<Route path='/categories'>
<TagView/>
</Route>
</Switch>
);
}
}
export default CategoryPage;
@@ -0,0 +1,74 @@
import {shallow} from 'enzyme';
import React from 'react';
import {CategoryView} from './CategoryView';
describe('<CategoryView/>', function () {
function instance() {
return shallow(<CategoryView match={{params: {id: 10}}} history={{push: jest.fn()}}/>);
}
it('renders without crashing ', function () {
const wrapper = instance();
wrapper.unmount();
});
it('test backbutton', function () {
const wrapper = instance();
const func = jest.fn();
wrapper.setProps({history: {push: func}});
expect(func).toHaveBeenCalledTimes(0);
wrapper.find('button').simulate('click');
expect(func).toHaveBeenCalledTimes(1);
});
it('test delete of tag', function () {
const wrapper = instance();
callAPIMock({result: 'success'});
// simulate button click
wrapper.find('Button').props().onClick();
expect(wrapper.instance().props.history.push).toHaveBeenCalledTimes(1);
});
it('test delete of non empty tag', function () {
const wrapper = instance();
callAPIMock({result: 'not empty tag'});
// simulate button click
wrapper.find('Button').props().onClick();
// expect SubmitPopup showing
expect(wrapper.find('SubmitPopup')).toHaveLength(1);
// mock deleteTag function
wrapper.instance().deleteTag = jest.fn((v) => {});
// simulate submit
wrapper.find('SubmitPopup').props().submit();
// expect deleteTag function to have been called with force parameter
expect(wrapper.instance().deleteTag).toHaveBeenCalledWith(true);
});
it('test cancel of ', function () {
const wrapper = instance();
callAPIMock({result: 'not empty tag'});
// simulate button click
wrapper.find('Button').props().onClick();
// expect SubmitPopup showing
expect(wrapper.find('SubmitPopup')).toHaveLength(1);
// mock deleteTag function
wrapper.instance().deleteTag = jest.fn((v) => {});
// simulate submit
wrapper.find('SubmitPopup').props().onHide();
// expect deleteTag function to have been called with force parameter
expect(wrapper.instance().deleteTag).toHaveBeenCalledTimes(0);
});
});
+123
View File
@@ -0,0 +1,123 @@
import {RouteComponentProps} from 'react-router';
import React from 'react';
import VideoContainer from '../../elements/VideoContainer/VideoContainer';
import {APINode, callAPI} from '../../utils/Api';
import {withRouter} from 'react-router-dom';
import {VideoTypes} from '../../types/ApiTypes';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
import SideBar, {SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag';
import {DefaultTags, GeneralSuccess} from '../../types/GeneralTypes';
import {Button} from '../../elements/GPElements/Button';
import SubmitPopup from '../../elements/Popups/SubmitPopup/SubmitPopup';
interface CategoryViewProps extends RouteComponentProps<{ id: string }> {}
interface CategoryViewState {
loaded: boolean;
submitForceDelete: boolean;
}
/**
* plain class (for unit testing only)
*/
export class CategoryView extends React.Component<CategoryViewProps, CategoryViewState> {
private videodata: VideoTypes.VideoUnloadedType[] = [];
constructor(props: CategoryViewProps) {
super(props);
this.state = {
loaded: false,
submitForceDelete: false
};
}
componentDidMount(): void {
this.fetchVideoData(parseInt(this.props.match.params.id));
}
componentDidUpdate(prevProps: Readonly<CategoryViewProps>, prevState: Readonly<CategoryViewState>): void {
// trigger video refresh if id changed
if (prevProps.match.params.id !== this.props.match.params.id) {
this.setState({loaded: false});
this.fetchVideoData(parseInt(this.props.match.params.id));
}
}
render(): JSX.Element {
return (
<>
<PageTitle
title='Categories'
subtitle={this.videodata.length + ' Videos'}/>
<SideBar>
<SideBarTitle>Default Tags:</SideBarTitle>
<Tag tagInfo={DefaultTags.all}/>
<Tag tagInfo={DefaultTags.fullhd}/>
<Tag tagInfo={DefaultTags.hd}/>
<Tag tagInfo={DefaultTags.lowq}/>
<Line/>
<Button title='Delete Tag' onClick={(): void => {this.deleteTag(false);}} color={{backgroundColor: 'red'}}/>
</SideBar>
{this.state.loaded ?
<VideoContainer
data={this.videodata}/> : null}
<button data-testid='backbtn' className='btn btn-success'
onClick={(): void => {
this.props.history.push('/categories');
}}>Back to Categories
</button>
{this.handlePopups()}
</>
);
}
private handlePopups(): JSX.Element {
if (this.state.submitForceDelete) {
return (<SubmitPopup
onHide={(): void => this.setState({submitForceDelete: false})}
submit={(): void => {this.deleteTag(true);}}/>);
} else {
return <></>;
}
}
/**
* fetch data for a specific tag from backend
* @param id tagid
*/
private fetchVideoData(id: number): void {
callAPI<VideoTypes.VideoUnloadedType[]>(APINode.Video, {action: 'getMovies', tag: id}, result => {
this.videodata = result;
this.setState({loaded: true});
});
}
/**
* delete the current tag
*/
private deleteTag(force: boolean): void {
callAPI<GeneralSuccess>(APINode.Tags, {
action: 'deleteTag',
tagId: parseInt(this.props.match.params.id),
force: force
}, result => {
console.log(result.result);
if (result.result === 'success') {
this.props.history.push('/categories');
} else if (result.result === 'not empty tag') {
// show submisison tag to ask if really delete
this.setState({submitForceDelete: true});
}
});
}
}
/**
* export with react Router wrapped (default use)
*/
export const CategoryViewWR = withRouter(CategoryView);
+43
View File
@@ -0,0 +1,43 @@
import {shallow} from 'enzyme';
import React from 'react';
import TagView from './TagView';
describe('<TagView/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<TagView/>);
wrapper.unmount();
});
it('test Tag insertion', function () {
const wrapper = shallow(<TagView/>);
wrapper.setState({loadedtags: [{tag_name: 'test', tag_id: 42}]});
expect(wrapper.find('TagPreview')).toHaveLength(1);
});
it('test new tag popup', function () {
const wrapper = shallow(<TagView/>);
expect(wrapper.find('NewTagPopup')).toHaveLength(0);
wrapper.find('[data-testid="btnaddtag"]').simulate('click');
// newtagpopup should be showing now
expect(wrapper.find('NewTagPopup')).toHaveLength(1);
});
it('test add popup', function () {
const wrapper = shallow(<TagView/>);
expect(wrapper.find('NewTagPopup')).toHaveLength(0);
wrapper.setState({popupvisible: true});
expect(wrapper.find('NewTagPopup')).toHaveLength(1);
});
it('test hiding of popup', function () {
const wrapper = shallow(<TagView/>);
wrapper.setState({popupvisible: true});
wrapper.find('NewTagPopup').props().onHide();
expect(wrapper.find('NewTagPopup')).toHaveLength(0);
});
});
+90
View File
@@ -0,0 +1,90 @@
import {TagType} from '../../types/VideoTypes';
import React from 'react';
import videocontainerstyle from '../../elements/VideoContainer/VideoContainer.module.css';
import {Link} from 'react-router-dom';
import {TagPreview} from '../../elements/Preview/Preview';
import {APINode, callAPI} from '../../utils/Api';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
import SideBar, {SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag';
import {DefaultTags} from '../../types/GeneralTypes';
import NewTagPopup from '../../elements/Popups/NewTagPopup/NewTagPopup';
interface TagViewState {
loadedtags: TagType[];
popupvisible: boolean;
}
interface props {}
class TagView extends React.Component<props, TagViewState> {
constructor(props: props) {
super(props);
this.state = {
loadedtags: [],
popupvisible: false
};
}
componentDidMount(): void {
this.loadTags();
}
render(): JSX.Element {
return (
<>
<PageTitle
title='Categories'
subtitle={this.state.loadedtags.length + ' different Tags'}/>
<SideBar>
<SideBarTitle>Default Tags:</SideBarTitle>
<Tag tagInfo={DefaultTags.all}/>
<Tag tagInfo={DefaultTags.fullhd}/>
<Tag tagInfo={DefaultTags.hd}/>
<Tag tagInfo={DefaultTags.lowq}/>
<Line/>
<button data-testid='btnaddtag' className='btn btn-success' onClick={(): void => {
this.setState({popupvisible: true});
}}>Add a new Tag!
</button>
</SideBar>
<div className={videocontainerstyle.maincontent}>
{this.state.loadedtags ?
this.state.loadedtags.map((m) => (
<Link to={'/categories/' + m.tag_id} key={m.tag_id}>
<TagPreview name={m.tag_name}/></Link>
)) :
'loading'}
</div>
{this.handlePopups()}
</>
);
}
/**
* load all available tags from db.
*/
loadTags(): void {
callAPI<TagType[]>(APINode.Tags, {action: 'getAllTags'}, result => {
this.setState({loadedtags: result});
});
}
private handlePopups(): JSX.Element {
if (this.state.popupvisible) {
return (
<NewTagPopup onHide={(): void => {
this.setState({popupvisible: false});
this.loadTags();
}}/>
);
} else {
return (<></>);
}
}
}
export default TagView;
-173
View File
@@ -1,173 +0,0 @@
import React from 'react';
import SideBar, {SideBarItem, SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag';
import VideoContainer from '../../elements/VideoContainer/VideoContainer';
import style from './HomePage.module.css';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
/**
* The home page component showing on the initial pageload
*/
class HomePage extends React.Component {
/** keyword variable needed temporary store search keyword */
keyword = '';
constructor(props, context) {
super(props, context);
this.state = {
sideinfo: {
videonr: null,
fullhdvideonr: null,
hdvideonr: null,
sdvideonr: null,
tagnr: null
},
subtitle: 'All Videos',
data: [],
selectionnr: 0
};
}
componentDidMount() {
// initial get of all videos
this.fetchVideoData('All');
this.fetchStartData();
}
/**
* fetch available videos for specified tag
* this function clears all preview elements an reloads gravity with tag
*
* @param tag tag to fetch videos
*/
fetchVideoData(tag) {
const updateRequest = new FormData();
updateRequest.append('action', 'getMovies');
updateRequest.append('tag', tag);
console.log('fetching data from' + tag);
// fetch all videos available
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
this.setState({
data: []
});
this.setState({
data: result,
selectionnr: result.length,
tag: tag + ' Videos'
});
}))
.catch(() => {
console.log('no connection to backend');
});
}
/**
* fetch the necessary data for left info box
*/
fetchStartData() {
const updateRequest = new FormData();
updateRequest.append('action', 'getStartData');
// fetch all videos available
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
this.setState({
sideinfo: {
videonr: result['total'],
fullhdvideonr: result['fullhd'],
hdvideonr: result['hd'],
sdvideonr: result['sd'],
tagnr: result['tags']
}
});
}))
.catch(() => {
console.log('no connection to backend');
});
}
/**
* search for a keyword in db and update previews
*
* @param keyword The keyword to search for
*/
searchVideos(keyword) {
console.log('search called');
const updateRequest = new FormData();
updateRequest.append('action', 'getSearchKeyWord');
updateRequest.append('keyword', keyword);
// fetch all videos available
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
this.setState({
data: []
});
this.setState({
data: result,
selectionnr: result.length,
tag: 'Search result: ' + keyword
});
}))
.catch(() => {
console.log('no connection to backend');
});
}
render() {
return (
<>
<PageTitle
title='Home Page'
subtitle={this.state.subtitle + ' - ' + this.state.selectionnr}>
<form className={'form-inline ' + style.searchform} onSubmit={(e) => {
e.preventDefault();
this.searchVideos(this.keyword);
}}>
<input data-testid='searchtextfield' className='form-control mr-sm-2'
type='text' placeholder='Search'
onChange={(e) => {
this.keyword = e.target.value;
}}/>
<button data-testid='searchbtnsubmit' className='btn btn-success' type='submit'>Search</button>
</form>
</PageTitle>
<SideBar>
<SideBarTitle>Infos:</SideBarTitle>
<Line/>
<SideBarItem><b>{this.state.sideinfo.videonr}</b> Videos Total!</SideBarItem>
<SideBarItem><b>{this.state.sideinfo.fullhdvideonr}</b> FULL-HD Videos!</SideBarItem>
<SideBarItem><b>{this.state.sideinfo.hdvideonr}</b> HD Videos!</SideBarItem>
<SideBarItem><b>{this.state.sideinfo.sdvideonr}</b> SD Videos!</SideBarItem>
<SideBarItem><b>{this.state.sideinfo.tagnr}</b> different Tags!</SideBarItem>
<Line/>
<SideBarTitle>Default Tags:</SideBarTitle>
<Tag onclick={() => this.fetchVideoData('All')}>All</Tag>
<Tag onclick={() => this.fetchVideoData('FullHd')}>FullHd</Tag>
<Tag onclick={() => this.fetchVideoData('LowQuality')}>LowQuality</Tag>
<Tag onclick={() => this.fetchVideoData('HD')}>HD</Tag>
</SideBar>
{this.state.data.length !== 0 ?
<VideoContainer
data={this.state.data}
viewbinding={this.props.viewbinding}/> :
<div>No Data found!</div>}
<div className={style.rightinfo}>
</div>
</>
);
}
}
export default HomePage;
+27 -13
View File
@@ -1,7 +1,8 @@
import {shallow} from 'enzyme';
import React from 'react';
import HomePage from './HomePage';
import {HomePage} from './HomePage';
import VideoContainer from '../../elements/VideoContainer/VideoContainer';
import {SearchHandling} from './SearchHandling';
describe('<HomePage/>', function () {
it('renders without crashing ', function () {
@@ -54,23 +55,15 @@ describe('<HomePage/>', function () {
});
});
it('test form submit', done => {
global.fetch = global.prepareFetchApi([{}, {}]);
it('test form submit', () => {
const func = jest.fn();
const wrapper = shallow(<HomePage/>);
wrapper.setProps({history: {push: () => func()}});
const fakeEvent = {preventDefault: () => console.log('preventDefault')};
wrapper.find('.searchform').simulate('submit', fakeEvent);
expect(wrapper.state().selectionnr).toBe(0);
process.nextTick(() => {
// state to be set correctly with response
expect(wrapper.state().selectionnr).toBe(2);
global.fetch.mockClear();
done();
});
expect(func).toHaveBeenCalledTimes(1);
});
it('test no backend connection behaviour', done => {
@@ -123,3 +116,24 @@ describe('<HomePage/>', function () {
testBtn(tags.first());
});
});
describe('<SearchHandling/>', () => {
it('renders without crashing', function () {
const wrapper = shallow(<SearchHandling match={{params: {name: 'testname'}}}/>);
wrapper.unmount();
});
it('renders videos correctly into container', function () {
const wrapper = shallow(<SearchHandling match={{params: {name: 'testname'}}}/>);
wrapper.setState({
data: [{
movie_id: 42,
movie_name: 'testname'
}]
});
// expect video container to be visible
expect(wrapper.find('VideoContainer')).toHaveLength(1);
});
});
+148
View File
@@ -0,0 +1,148 @@
import React from 'react';
import SideBar, {SideBarItem, SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag';
import VideoContainer from '../../elements/VideoContainer/VideoContainer';
import style from './HomePage.module.css';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
import {APINode, callAPI} from '../../utils/Api';
import {Route, Switch, withRouter} from 'react-router-dom';
import {RouteComponentProps} from 'react-router';
import SearchHandling from './SearchHandling';
import {VideoTypes} from '../../types/ApiTypes';
interface props extends RouteComponentProps {}
interface state {
sideinfo: {
videonr: number,
fullhdvideonr: number,
hdvideonr: number,
sdvideonr: number,
tagnr: number
},
subtitle: string,
data: VideoTypes.VideoUnloadedType[],
selectionnr: number
}
/**
* The home page component showing on the initial pageload
*/
export class HomePage extends React.Component<props, state> {
/** keyword variable needed temporary store search keyword */
keyword = '';
constructor(props: props) {
super(props);
this.state = {
sideinfo: {
videonr: 0,
fullhdvideonr: 0,
hdvideonr: 0,
sdvideonr: 0,
tagnr: 0
},
subtitle: 'All Videos',
data: [],
selectionnr: 0
};
}
componentDidMount(): void {
// initial get of all videos
this.fetchVideoData('All');
this.fetchStartData();
}
/**
* fetch available videos for specified tag
* this function clears all preview elements an reloads gravity with tag
*
* @param tag tag to fetch videos
*/
fetchVideoData(tag: string): void {
callAPI(APINode.Video, {action: 'getMovies', tag: tag}, (result: VideoTypes.VideoUnloadedType[]) => {
this.setState({
data: []
});
this.setState({
data: result,
selectionnr: result.length,
subtitle: `${tag} Videos`
});
});
}
/**
* fetch the necessary data for left info box
*/
fetchStartData(): void {
callAPI(APINode.Video, {action: 'getStartData'}, (result: VideoTypes.startDataType) => {
this.setState({
sideinfo: {
videonr: result['total'],
fullhdvideonr: result['fullhd'],
hdvideonr: result['hd'],
sdvideonr: result['sd'],
tagnr: result['tags']
}
});
});
}
render(): JSX.Element {
return (
<>
<Switch>
<Route path='/search/:name'>
<SearchHandling/>
</Route>
<Route path='/'>
<PageTitle
title='Home Page'
subtitle={this.state.subtitle + ' - ' + this.state.selectionnr}>
<form className={'form-inline ' + style.searchform} onSubmit={(e): void => {
e.preventDefault();
this.props.history.push('/search/' + this.keyword);
}}>
<input data-testid='searchtextfield' className='form-control mr-sm-2'
type='text' placeholder='Search'
onChange={(e): void => {
this.keyword = e.target.value;
}}/>
<button data-testid='searchbtnsubmit' className='btn btn-success' type='submit'>Search</button>
</form>
</PageTitle>
<SideBar>
<SideBarTitle>Infos:</SideBarTitle>
<Line/>
<SideBarItem><b>{this.state.sideinfo.videonr}</b> Videos Total!</SideBarItem>
<SideBarItem><b>{this.state.sideinfo.fullhdvideonr}</b> FULL-HD Videos!</SideBarItem>
<SideBarItem><b>{this.state.sideinfo.hdvideonr}</b> HD Videos!</SideBarItem>
<SideBarItem><b>{this.state.sideinfo.sdvideonr}</b> SD Videos!</SideBarItem>
<SideBarItem><b>{this.state.sideinfo.tagnr}</b> different Tags!</SideBarItem>
<Line/>
<SideBarTitle>Default Tags:</SideBarTitle>
<Tag tagInfo={{tag_name: 'All', tag_id: -1}} onclick={(): void => this.fetchVideoData('All')}/>
<Tag tagInfo={{tag_name: 'FullHd', tag_id: -1}} onclick={(): void => this.fetchVideoData('FullHd')}/>
<Tag tagInfo={{tag_name: 'LowQuality', tag_id: -1}} onclick={(): void => this.fetchVideoData('LowQuality')}/>
<Tag tagInfo={{tag_name: 'HD', tag_id: -1}} onclick={(): void => this.fetchVideoData('HD')}/>
</SideBar>
{this.state.data.length !== 0 ?
<VideoContainer
data={this.state.data}/> :
<div>No Data found!</div>}
<div className={style.rightinfo}>
</div>
</Route>
</Switch>
</>
);
}
}
export default withRouter(HomePage);
+70
View File
@@ -0,0 +1,70 @@
import {RouteComponentProps} from 'react-router';
import React from 'react';
import {withRouter} from 'react-router-dom';
import {APINode, callAPI} from '../../utils/Api';
import VideoContainer from '../../elements/VideoContainer/VideoContainer';
import PageTitle from '../../elements/PageTitle/PageTitle';
import SideBar from '../../elements/SideBar/SideBar';
import {VideoTypes} from '../../types/ApiTypes';
interface params {
name: string;
}
interface props extends RouteComponentProps<params> {}
interface state {
data: VideoTypes.VideoUnloadedType[];
}
export class SearchHandling extends React.Component<props, state> {
constructor(props: props) {
super(props);
this.state = {
data: []
};
}
componentDidMount(): void {
this.searchVideos(this.props.match.params.name);
}
render(): JSX.Element {
return (
<>
<PageTitle title='Search' subtitle={this.props.match.params.name + ': ' + this.state.data.length}/>
<SideBar hiddenFrame/>
{this.getVideoData()}
</>
);
}
/**
* get videocontainer if data loaded
*/
getVideoData(): JSX.Element {
if (this.state.data.length !== 0) {
return (
<VideoContainer data={this.state.data}/>
);
} else {
return (<div>No Data found!</div>);
}
}
/**
* search for a keyword in db and update previews
*
* @param keyword The keyword to search for
*/
searchVideos(keyword: string): void {
callAPI(APINode.Video, {action: 'getSearchKeyWord', keyword: keyword}, (result: VideoTypes.VideoUnloadedType[]) => {
this.setState({
data: result
});
});
}
}
export default withRouter(SearchHandling);
-283
View File
@@ -1,283 +0,0 @@
import React from 'react';
import style from './Player.module.css';
import {PlyrComponent} from 'plyr-react';
import SideBar, {SideBarItem, SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag';
import AddTagPopup from '../../elements/AddTagPopup/AddTagPopup';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
/**
* Player page loads when a video is selected to play and handles the video view
* and actions such as tag adding and liking
*/
class Player extends React.Component {
options = {
controls: [
'play-large', // The large play button in the center
'play', // Play/pause playback
'progress', // The progress bar and scrubber for playback and buffering
'current-time', // The current time of playback
'duration', // The full duration of the media
'mute', // Toggle mute
'volume', // Volume control
'captions', // Toggle captions
'settings', // Settings menu
'airplay', // Airplay (currently Safari only)
'download', // Show a download button with a link to either the current source or a custom URL you specify in your options
'fullscreen' // Toggle fullscreen
]
};
constructor(props, context) {
super(props, context);
this.state = {
sources: null,
movie_id: null,
movie_name: null,
likes: null,
quality: null,
length: null,
tags: [],
suggesttag: [],
popupvisible: false
};
this.quickAddTag = this.quickAddTag.bind(this);
}
componentDidMount() {
this.fetchMovieData();
}
/**
* quick add callback to add tag to db and change gui correctly
* @param tagId id of tag to add
* @param tagName name of tag to add
*/
quickAddTag(tagId, tagName) {
const updateRequest = new FormData();
updateRequest.append('action', 'addTag');
updateRequest.append('id', tagId);
updateRequest.append('movieid', this.props.movie_id);
fetch('/api/tags.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
if (result.result !== 'success') {
console.error('error occured while writing to db -- todo error handling');
console.error(result.result);
} else {
// check if tag has already been added
const tagIndex = this.state.tags.map(function (e) {
return e.tag_name;
}).indexOf(tagName);
// only add tag if it isn't already there
if (tagIndex === -1) {
// update tags if successful
let array = [...this.state.suggesttag]; // make a separate copy of the array (because of setState)
const quickaddindex = this.state.suggesttag.map(function (e) {
return e.tag_id;
}).indexOf(tagId);
// check if tag is available in quickadds
if (quickaddindex !== -1) {
array.splice(quickaddindex, 1);
this.setState({
tags: [...this.state.tags, {tag_name: tagName}],
suggesttag: array
});
} else {
this.setState({
tags: [...this.state.tags, {tag_name: tagName}]
});
}
}
}
}));
}
/**
* handle the popovers generated according to state changes
* @returns {JSX.Element}
*/
handlePopOvers() {
return (
<>
{this.state.popupvisible ?
<AddTagPopup show={this.state.popupvisible}
onHide={() => {
this.setState({popupvisible: false});
}}
submit={this.quickAddTag}
movie_id={this.state.movie_id}/> :
null
}
</>
);
}
/**
* generate sidebar with all items
*/
assembleSideBar() {
return (
<SideBar>
<SideBarTitle>Infos:</SideBarTitle>
<Line/>
<SideBarItem><b>{this.state.likes}</b> Likes!</SideBarItem>
{this.state.quality !== 0 ?
<SideBarItem><b>{this.state.quality}p</b> Quality!</SideBarItem> : null}
{this.state.length !== 0 ?
<SideBarItem><b>{Math.round(this.state.length / 60)}</b> Minutes of
length!</SideBarItem> : null}
<Line/>
<SideBarTitle>Tags:</SideBarTitle>
{this.state.tags.map((m) => (
<Tag
key={m.tag_name}
viewbinding={this.props.viewbinding}>{m.tag_name}</Tag>
))}
<Line/>
<SideBarTitle>Tag Quickadd:</SideBarTitle>
{this.state.suggesttag.map((m) => (
<Tag
key={m.tag_name}
onclick={() => {
this.quickAddTag(m.tag_id, m.tag_name);
}}>
{m.tag_name}
</Tag>
))}
</SideBar>
);
}
render() {
return (
<div id='videocontainer'>
<PageTitle
title='Watch'
subtitle={this.state.movie_name}/>
{this.assembleSideBar()}
<div className={style.videowrapper}>
{/* video component is added here */}
{this.state.sources ? <PlyrComponent
className='myvideo'
sources={this.state.sources}
options={this.options}/> :
<div>not loaded yet</div>}
<div className={style.videoactions}>
<button className={style.button} style={{backgroundColor: 'green'}} onClick={() => this.likebtn()}>Like this Video!</button>
<button className={style.button} style={{backgroundColor: '#3574fe'}} onClick={() => this.setState({popupvisible: true})}>
Give this Video a Tag
</button>
<button className={style.button} style={{backgroundColor: 'red'}} onClick={() => {
this.deleteVideo();
}}>Delete Video
</button>
</div>
</div>
<button className={style.closebutton} onClick={() => this.closebtn()}>Close</button>
{
// handle the popovers switched on and off according to state changes
this.handlePopOvers()
}
</div>
);
}
/**
* fetch all the required infos of a video from backend
*/
fetchMovieData() {
const updateRequest = new FormData();
updateRequest.append('action', 'loadVideo');
updateRequest.append('movieid', this.props.movie_id);
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json())
.then((result) => {
this.setState({
sources: {
type: 'video',
sources: [
{
src: result.movie_url,
type: 'video/mp4',
size: 1080
}
],
poster: result.thumbnail
},
movie_id: result.movie_id,
movie_name: result.movie_name,
likes: result.likes,
quality: result.quality,
length: result.length,
tags: result.tags,
suggesttag: result.suggesttag
});
console.log(this.state);
});
}
/**
* click handler for the like btn
*/
likebtn() {
const updateRequest = new FormData();
updateRequest.append('action', 'addLike');
updateRequest.append('movieid', this.props.movie_id);
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
if (result.result === 'success') {
// likes +1 --> avoid reload of all data
this.setState({likes: this.state.likes + 1});
} else {
console.error('an error occured while liking');
console.error(result);
}
}));
}
/**
* closebtn click handler
* calls callback to viewbinding to show previous page agains
*/
closebtn() {
this.props.viewbinding.returnToLastElement();
}
/**
* delete the current video and return to last page
*/
deleteVideo() {
const updateRequest = new FormData();
updateRequest.append('action', 'deleteVideo');
updateRequest.append('movieid', this.props.movie_id);
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
if (result.result === 'success') {
// return to last element if successful
this.props.viewbinding.returnToLastElement();
} else {
console.error('an error occured while liking');
console.error(result);
}
}));
}
}
export default Player;
+23 -5
View File
@@ -20,10 +20,28 @@
margin-top: 15px;
}
.button {
padding: 6px;
border-radius: 5px;
margin-right: 15px;
.actorcontainer {
margin-top: 13px;
}
.actorAddTile {
color: white;
border-width: 0;
cursor: pointer;
float: left;
padding-left: 25px;
padding-top: 50px;
transition: opacity ease 0.5s;
}
.actorAddTile:hover {
opacity: 0.7;
transition: opacity ease 0.5s;
}
.actorAddTile_thumbnail {
height: 90px;
}
.actorAddTile_name {
bottom: 0;
}
+135 -24
View File
@@ -1,15 +1,22 @@
import {shallow} from 'enzyme';
import React from 'react';
import Player from './Player';
import {Player} from './Player';
import {callAPI} from '../../utils/Api';
describe('<Player/>', function () {
// help simulating id passed by url
function instance() {
return shallow(<Player match={{params: {id: 10}}}/>);
}
it('renders without crashing ', function () {
const wrapper = shallow(<Player/>);
const wrapper = instance();
wrapper.unmount();
});
it('plyr insertion', function () {
const wrapper = shallow(<Player/>);
const wrapper = instance();
wrapper.setState({
sources: [
@@ -20,15 +27,16 @@ describe('<Player/>', function () {
}
]
});
expect(wrapper.find('r')).toHaveLength(1);
expect(wrapper.find('Plyr').dive().find('video')).toHaveLength(1);
});
function simulateLikeButtonClick() {
const wrapper = shallow(<Player/>);
const wrapper = instance();
// initial fetch for getting movie data
expect(global.fetch).toHaveBeenCalledTimes(1);
wrapper.find('.videoactions').find('button').first().simulate('click');
wrapper.find('.videoactions').find('Button').first().simulate('click');
// fetch for liking
expect(global.fetch).toHaveBeenCalledTimes(2);
@@ -68,26 +76,28 @@ describe('<Player/>', function () {
});
it('show tag popup', function () {
const wrapper = shallow(<Player/>);
const wrapper = instance();
expect(wrapper.find('AddTagPopup')).toHaveLength(0);
// todo dynamic button find without index
wrapper.find('.videoactions').find('button').at(1).simulate('click');
wrapper.find('.videoactions').find('Button').at(1).simulate('click');
// addtagpopup should be showing now
expect(wrapper.find('AddTagPopup')).toHaveLength(1);
});
it('test delete button', done => {
const wrapper = shallow(<Player viewbinding={{
returnToLastElement: jest.fn()
}}/>);
const wrapper = instance();
wrapper.setProps({history: {goBack: jest.fn()}});
global.fetch = prepareFetchApi({result: 'success'});
wrapper.find('.videoactions').find('button').at(2).simulate('click');
wrapper.find('.videoactions').find('Button').at(2).simulate('click');
process.nextTick(() => {
// refetch is called so fetch called 3 times
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.viewbinding.returnToLastElement).toHaveBeenCalledTimes(1);
expect(wrapper.instance().props.history.goBack).toHaveBeenCalledTimes(1);
global.fetch.mockClear();
done();
@@ -95,25 +105,20 @@ describe('<Player/>', function () {
});
it('hide click ', function () {
const wrapper = shallow(<Player/>);
const wrapper = instance();
const func = jest.fn();
wrapper.setProps({
viewbinding: {
returnToLastElement: () => {
func();
}
}
});
wrapper.setProps({history: {goBack: func}});
expect(func).toHaveBeenCalledTimes(0);
wrapper.find('.closebutton').simulate('click');
// addtagpopup should be showing now
// backstack should be popped once
expect(func).toHaveBeenCalledTimes(1);
});
it('inserts Tags correctly', function () {
const wrapper = shallow(<Player/>);
const wrapper = instance();
expect(wrapper.find('Tag')).toHaveLength(0);
@@ -166,8 +171,60 @@ describe('<Player/>', function () {
});
});
it('showspopups correctly', function () {
const wrapper = instance();
wrapper.setState({popupvisible: true}, () => {
// is the AddTagpopu rendered?
expect(wrapper.find('AddTagPopup')).toHaveLength(1);
wrapper.setState({popupvisible: false, actorpopupvisible: true}, () => {
// actorpopup rendred and tagpopup hidden?
expect(wrapper.find('AddTagPopup')).toHaveLength(0);
expect(wrapper.find('AddActorPopup')).toHaveLength(1);
});
});
});
it('quickadd tag correctly', function () {
const wrapper = instance();
global.callAPIMock({result: 'success'});
wrapper.setState({suggesttag: [{tag_name: 'test', tag_id: 1}]}, () => {
// mock funtion should have not been called
expect(callAPI).toBeCalledTimes(0);
wrapper.find('Tag').findWhere(p => p.props().tagInfo.tag_name === 'test').dive().simulate('click');
// mock function should have been called once
expect(callAPI).toBeCalledTimes(1);
// expect tag added to video tags
expect(wrapper.state().tags).toMatchObject([{tag_name: 'test'}]);
// expect tag to be removed from tag suggestions
expect(wrapper.state().suggesttag).toHaveLength(0);
});
});
it('test adding of already existing tag', function () {
const wrapper = instance();
global.callAPIMock({result: 'success'});
wrapper.setState({suggesttag: [{tag_name: 'test', tag_id: 1}], tags: [{tag_name: 'test', tag_id: 1}]}, () => {
// mock funtion should have not been called
expect(callAPI).toBeCalledTimes(0);
wrapper.find('Tag').findWhere(p => p.props().tagInfo.tag_name === 'test').last().dive().simulate('click');
// mock function should have been called once
expect(callAPI).toBeCalledTimes(1);
// there should not been added a duplicate of tag so object stays same...
expect(wrapper.state().tags).toMatchObject([{tag_name: 'test'}]);
// the suggestion tag shouldn't be removed (this can't actually happen in rl
// because backennd doesn't give dupliacate suggestiontags)
expect(wrapper.state().suggesttag).toHaveLength(1);
});
});
function generatetag() {
const wrapper = shallow(<Player/>);
const wrapper = instance();
expect(wrapper.find('Tag')).toHaveLength(0);
@@ -181,4 +238,58 @@ describe('<Player/>', function () {
return wrapper;
}
it('test addactor popup showing', function () {
const wrapper = instance();
expect(wrapper.find('AddActorPopup')).toHaveLength(0);
wrapper.instance().addActor();
// check if popup is visible
expect(wrapper.find('AddActorPopup')).toHaveLength(1);
});
it('test hiding of addactor popup', function () {
const wrapper = instance();
wrapper.instance().addActor();
expect(wrapper.find('AddActorPopup')).toHaveLength(1);
wrapper.find('AddActorPopup').props().onHide();
expect(wrapper.find('AddActorPopup')).toHaveLength(0);
});
it('test addtagpopup hiding', function () {
const wrapper = instance();
wrapper.setState({popupvisible: true});
expect(wrapper.find('AddTagPopup')).toHaveLength(1);
wrapper.find('AddTagPopup').props().onHide();
expect(wrapper.find('AddTagPopup')).toHaveLength(0);
});
it('test insertion of actor tiles', function () {
const wrapper = instance();
wrapper.setState({
actors: [{
thumbnail: '',
name: 'testname',
actor_id: 42
}]
});
expect(wrapper.find('ActorTile')).toHaveLength(1);
});
it('test Addactor button', function () {
const wrapper = instance();
expect(wrapper.state().actorpopupvisible).toBe(false);
wrapper.find('.actorAddTile').simulate('click');
expect(wrapper.state().actorpopupvisible).toBe(true);
});
});
+334
View File
@@ -0,0 +1,334 @@
import React from 'react';
import style from './Player.module.css';
import plyrstyle from 'plyr-react/dist/plyr.css';
import {Plyr} from 'plyr-react';
import SideBar, {SideBarItem, SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag';
import AddTagPopup from '../../elements/Popups/AddTagPopup/AddTagPopup';
import PageTitle, {Line} from '../../elements/PageTitle/PageTitle';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faPlusCircle} from '@fortawesome/free-solid-svg-icons';
import AddActorPopup from '../../elements/Popups/AddActorPopup/AddActorPopup';
import ActorTile from '../../elements/ActorTile/ActorTile';
import {withRouter} from 'react-router-dom';
import {APINode, callAPI, getBackendDomain} from '../../utils/Api';
import {RouteComponentProps} from 'react-router';
import {GeneralSuccess} from '../../types/GeneralTypes';
import {ActorType, TagType} from '../../types/VideoTypes';
import PlyrJS from 'plyr';
import {Button} from '../../elements/GPElements/Button';
import {VideoTypes} from '../../types/ApiTypes';
interface myprops extends RouteComponentProps<{ id: string }> {}
interface mystate {
sources?: PlyrJS.SourceInfo,
movie_id: number,
movie_name: string,
likes: number,
quality: number,
length: number,
tags: TagType[],
suggesttag: TagType[],
popupvisible: boolean,
actorpopupvisible: boolean,
actors: ActorType[]
}
/**
* Player page loads when a video is selected to play and handles the video view
* and actions such as tag adding and liking
*/
export class Player extends React.Component<myprops, mystate> {
options: PlyrJS.Options = {
controls: [
'play-large', // The large play button in the center
'play', // Play/pause playback
'progress', // The progress bar and scrubber for playback and buffering
'current-time', // The current time of playback
'duration', // The full duration of the media
'mute', // Toggle mute
'volume', // Volume control
'captions', // Toggle captions
'settings', // Settings menu
'airplay', // Airplay (currently Safari only)
'download', // Show a download button with a link to either the current source or a custom URL you specify in your options
'fullscreen' // Toggle fullscreen
]
};
constructor(props: myprops) {
super(props);
this.state = {
movie_id: -1,
movie_name: '',
likes: 0,
quality: 0,
length: 0,
tags: [],
suggesttag: [],
popupvisible: false,
actorpopupvisible: false,
actors: []
};
this.quickAddTag = this.quickAddTag.bind(this);
}
componentDidMount(): void {
// initial fetch of current movie data
this.fetchMovieData();
}
render(): JSX.Element {
return (
<div id='videocontainer'>
<PageTitle
title='Watch'
subtitle={this.state.movie_name}/>
{this.assembleSideBar()}
<div className={style.videowrapper}>
{/* video component is added here */}
{this.state.sources ? <Plyr
style={plyrstyle}
source={this.state.sources}
options={this.options}/> :
<div>not loaded yet</div>}
<div className={style.videoactions}>
<Button onClick={(): void => this.likebtn()} title='Like this Video!' color={{backgroundColor: 'green'}}/>
<Button onClick={(): void => this.setState({popupvisible: true})} title='Give this Video a Tag' color={{backgroundColor: '#3574fe'}}/>
<Button title='Delete Video' onClick={(): void => {this.deleteVideo();}} color={{backgroundColor: 'red'}}/>
</div>
{this.assembleActorTiles()}
</div>
<button className={style.closebutton} onClick={(): void => this.closebtn()}>Close</button>
{
// handle the popovers switched on and off according to state changes
this.handlePopOvers()
}
</div>
);
}
/**
* generate sidebar with all items
*/
assembleSideBar(): JSX.Element {
return (
<SideBar>
<SideBarTitle>Infos:</SideBarTitle>
<Line/>
<SideBarItem><b>{this.state.likes}</b> Likes!</SideBarItem>
{this.state.quality !== 0 ?
<SideBarItem><b>{this.state.quality}p</b> Quality!</SideBarItem> : null}
{this.state.length !== 0 ?
<SideBarItem><b>{Math.round(this.state.length / 60)}</b> Minutes of length!</SideBarItem> : null}
<Line/>
<SideBarTitle>Tags:</SideBarTitle>
{this.state.tags.map((m: TagType) => (
<Tag key={m.tag_id} tagInfo={m}/>
))}
<Line/>
<SideBarTitle>Tag Quickadd:</SideBarTitle>
{this.state.suggesttag.map((m: TagType) => (
<Tag
tagInfo={m}
key={m.tag_name}
onclick={(): void => {
this.quickAddTag(m.tag_id, m.tag_name);
}}/>
))}
</SideBar>
);
}
/**
* rendering of actor tiles
*/
private assembleActorTiles(): JSX.Element {
return (
<div className={style.actorcontainer}>
{this.state.actors ?
this.state.actors.map((actr: ActorType) => (
<ActorTile key={actr.actor_id} actor={actr}/>
)) : <></>
}
<div className={style.actorAddTile} onClick={(): void => {
this.addActor();
}}>
<div className={style.actorAddTile_thumbnail}>
<FontAwesomeIcon style={{
lineHeight: '130px'
}} icon={faPlusCircle} size='5x'/>
</div>
<div className={style.actorAddTile_name}>Add Actor</div>
</div>
</div>
);
}
/**
* handle the popovers generated according to state changes
* @returns {JSX.Element}
*/
handlePopOvers(): JSX.Element {
return (
<>
{this.state.popupvisible ?
<AddTagPopup onHide={(): void => {
this.setState({popupvisible: false});
}}
submit={this.quickAddTag}
movie_id={this.state.movie_id}/> :
null
}
{
this.state.actorpopupvisible ?
<AddActorPopup onHide={(): void => {
this.refetchActors();
this.setState({actorpopupvisible: false});
}} movie_id={this.state.movie_id}/> : null
}
</>
);
}
/**
* quick add callback to add tag to db and change gui correctly
* @param tagId id of tag to add
* @param tagName name of tag to add
*/
quickAddTag(tagId: number, tagName: string): void {
callAPI(APINode.Tags, {
action: 'addTag',
id: tagId,
movieid: this.props.match.params.id
}, (result: GeneralSuccess) => {
if (result.result !== 'success') {
console.error('error occured while writing to db -- todo error handling');
console.error(result.result);
} else {
// check if tag has already been added
const tagIndex = this.state.tags.map(function (e: TagType) {
return e.tag_name;
}).indexOf(tagName);
// only add tag if it isn't already there
if (tagIndex === -1) {
// update tags if successful
let array = [...this.state.suggesttag]; // make a separate copy of the array (because of setState)
const quickaddindex = this.state.suggesttag.map(function (e: TagType) {
return e.tag_id;
}).indexOf(tagId);
// check if tag is available in quickadds
if (quickaddindex !== -1) {
array.splice(quickaddindex, 1);
this.setState({
tags: [...this.state.tags, {tag_name: tagName, tag_id: tagId}],
suggesttag: array
});
} else {
this.setState({
tags: [...this.state.tags, {tag_name: tagName, tag_id: tagId}]
});
}
}
}
});
}
/**
* fetch all the required infos of a video from backend
*/
fetchMovieData(): void {
callAPI(APINode.Video, {action: 'loadVideo', movieid: this.props.match.params.id}, (result: VideoTypes.loadVideoType) => {
this.setState({
sources: {
type: 'video',
sources: [
{
src: getBackendDomain() + result.movie_url,
type: 'video/mp4',
size: 1080
}
],
poster: result.thumbnail
},
movie_id: result.movie_id,
movie_name: result.movie_name,
likes: result.likes,
quality: result.quality,
length: result.length,
tags: result.tags,
suggesttag: result.suggesttag,
actors: result.actors
});
});
}
/**
* click handler for the like btn
*/
likebtn(): void {
callAPI(APINode.Video, {action: 'addLike', movieid: this.props.match.params.id}, (result: GeneralSuccess) => {
if (result.result === 'success') {
// likes +1 --> avoid reload of all data
this.setState({likes: this.state.likes + 1});
} else {
console.error('an error occured while liking');
console.error(result);
}
});
}
/**
* closebtn click handler
* calls callback to viewbinding to show previous page agains
*/
closebtn(): void {
this.props.history.goBack();
}
/**
* delete the current video and return to last page
*/
deleteVideo(): void {
callAPI(APINode.Video, {action: 'deleteVideo', movieid: this.props.match.params.id}, (result: GeneralSuccess) => {
if (result.result === 'success') {
// return to last element if successful
this.props.history.goBack();
} else {
console.error('an error occured while liking');
console.error(result);
}
});
}
/**
* show the actor add popup
*/
addActor(): void {
this.setState({actorpopupvisible: true});
}
/**
* fetch the available video actors again
*/
refetchActors(): void {
callAPI<ActorType[]>(APINode.Actor, {action: 'getActorsOfVideo', videoid: this.props.match.params.id}, result => {
this.setState({actors: result});
});
}
}
export default withRouter(Player);
-89
View File
@@ -1,89 +0,0 @@
import React from 'react';
import style from './RandomPage.module.css';
import SideBar, {SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag';
import PageTitle from '../../elements/PageTitle/PageTitle';
import VideoContainer from '../../elements/VideoContainer/VideoContainer';
/**
* Randompage shuffles random viedeopreviews and provides a shuffle btn
*/
class RandomPage extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
videos: [],
tags: []
};
}
componentDidMount() {
this.loadShuffledvideos(4);
}
render() {
return (
<div>
<PageTitle title='Random Videos'
subtitle='4pc'/>
<SideBar>
<SideBarTitle>Visible Tags:</SideBarTitle>
{this.state.tags.map((m) => (
<Tag
key={m.tag_name}
viewbinding={this.props.viewbinding}>{m.tag_name}</Tag>
))}
</SideBar>
{this.state.videos.length !== 0 ?
<VideoContainer
data={this.state.videos}
viewbinding={this.props.viewbinding}>
<div className={style.Shufflebutton}>
<button onClick={() => this.shuffleclick()} className={style.btnshuffle}>Shuffle</button>
</div>
</VideoContainer>
:
<div>No Data found!</div>}
</div>
);
}
/**
* click handler for shuffle btn
*/
shuffleclick() {
this.loadShuffledvideos(4);
}
/**
* load random videos from backend
* @param nr number of videos to load
*/
loadShuffledvideos(nr) {
const updateRequest = new FormData();
updateRequest.append('action', 'getRandomMovies');
updateRequest.append('number', nr);
// fetch all videos available
fetch('/api/video.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
console.log(result);
this.setState({videos: []}); // needed to trigger rerender of main videoview
this.setState({
videos: result.rows,
tags: result.tags
});
}))
.catch(() => {
console.log('no connection to backend');
});
}
}
export default RandomPage;
+17
View File
@@ -1,6 +1,7 @@
import {shallow} from 'enzyme';
import React from 'react';
import RandomPage from './RandomPage';
import {callAPI} from '../../utils/Api';
describe('<RandomPage/>', function () {
it('renders without crashing ', function () {
@@ -45,4 +46,20 @@ describe('<RandomPage/>', function () {
expect(wrapper.find('Tag')).toHaveLength(2);
});
it('test shortkey press', function () {
let events = [];
document.addEventListener = jest.fn((event, cb) => {
events[event] = cb;
});
shallow(<RandomPage/>);
callAPIMock({rows: [], tags: []});
// trigger the keypress event
events.keyup({key: 's'});
expect(callAPI).toBeCalledTimes(1);
});
});
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import style from './RandomPage.module.css';
import SideBar, {SideBarTitle} from '../../elements/SideBar/SideBar';
import Tag from '../../elements/Tag/Tag';
import PageTitle from '../../elements/PageTitle/PageTitle';
import VideoContainer from '../../elements/VideoContainer/VideoContainer';
import {APINode, callAPI} from '../../utils/Api';
import {TagType} from '../../types/VideoTypes';
import {VideoTypes} from '../../types/ApiTypes';
import {addKeyHandler, removeKeyHandler} from '../../utils/ShortkeyHandler';
interface state {
videos: VideoTypes.VideoUnloadedType[];
tags: TagType[];
}
interface GetRandomMoviesType {
rows: VideoTypes.VideoUnloadedType[];
tags: TagType[];
}
/**
* Randompage shuffles random viedeopreviews and provides a shuffle btn
*/
class RandomPage extends React.Component<{}, state> {
constructor(props: {}) {
super(props);
this.state = {
videos: [],
tags: []
};
this.keypress = this.keypress.bind(this);
}
componentDidMount(): void {
addKeyHandler(this.keypress);
this.loadShuffledvideos(4);
}
componentWillUnmount(): void {
removeKeyHandler(this.keypress);
}
render(): JSX.Element {
return (
<div>
<PageTitle title='Random Videos'
subtitle='4pc'/>
<SideBar>
<SideBarTitle>Visible Tags:</SideBarTitle>
{this.state.tags.map((m) => (
<Tag key={m.tag_id} tagInfo={m}/>
))}
</SideBar>
{this.state.videos.length !== 0 ?
<VideoContainer
data={this.state.videos}>
<div className={style.Shufflebutton}>
<button onClick={(): void => this.shuffleclick()} className={style.btnshuffle}>Shuffle</button>
</div>
</VideoContainer>
:
<div>No Data found!</div>}
</div>
);
}
/**
* click handler for shuffle btn
*/
shuffleclick(): void {
this.loadShuffledvideos(4);
}
/**
* load random videos from backend
* @param nr number of videos to load
*/
loadShuffledvideos(nr: number): void {
callAPI<GetRandomMoviesType>(APINode.Video, {action: 'getRandomMovies', number: nr}, result => {
this.setState({videos: []}); // needed to trigger rerender of main videoview
this.setState({
videos: result.rows,
tags: result.tags
});
});
}
/**
* key event handling
* @param event keyevent
*/
private keypress(event: KeyboardEvent): void {
// bind s to shuffle
if (event.key === 's') {
this.loadShuffledvideos(4);
}
}
}
export default RandomPage;
@@ -8,18 +8,23 @@
width: 40%;
}
.customapiform {
margin-top: 15px;
width: 40%;
}
.infoheader {
display: flex;
flex-wrap: wrap;
}
.footer{
.footer {
bottom: 3px;
color: white;
opacity: 0.8;
position: fixed;
right: 0;
bottom: 3px;
width: 110px;
opacity: 0.8;
color: white;
}
/* On screens that are 722px wide or less, make the columns stack on top of each other instead of next to each other */
+15 -1
View File
@@ -1,7 +1,7 @@
import {shallow} from 'enzyme';
import React from 'react';
import GeneralSettings from './GeneralSettings';
import GlobalInfos from '../../GlobalInfos';
import GlobalInfos from '../../utils/GlobalInfos';
describe('<GeneralSettings/>', function () {
it('renders without crashing ', function () {
@@ -29,6 +29,13 @@ describe('<GeneralSettings/>', function () {
it('test savesettings', done => {
const wrapper = shallow(<GeneralSettings/>);
wrapper.setState({
passwordsupport: true,
videopath: '',
tvshowpath: '',
mediacentername: '',
tmdbsupport: true
});
global.fetch = global.prepareFetchApi({success: true});
@@ -47,6 +54,13 @@ describe('<GeneralSettings/>', function () {
it('test failing savesettings', done => {
const wrapper = shallow(<GeneralSettings/>);
wrapper.setState({
passwordsupport: true,
videopath: '',
tvshowpath: '',
mediacentername: '',
tmdbsupport: true
});
global.fetch = global.prepareFetchApi({success: false});
@@ -1,41 +1,65 @@
import React from 'react';
import {Button, Col, Form} from 'react-bootstrap';
import style from './GeneralSettings.module.css';
import GlobalInfos from '../../GlobalInfos';
import GlobalInfos from '../../utils/GlobalInfos';
import InfoHeaderItem from '../../elements/InfoHeaderItem/InfoHeaderItem';
import {faArchive, faBalanceScaleLeft, faRulerVertical} from '@fortawesome/free-solid-svg-icons';
import {faAddressCard} from '@fortawesome/free-regular-svg-icons';
import {version} from '../../../package.json';
import {APINode, callAPI, setCustomBackendDomain} from '../../utils/Api';
import {SettingsTypes} from '../../types/ApiTypes';
import {GeneralSuccess} from '../../types/GeneralTypes';
interface state {
passwordsupport: boolean,
tmdbsupport: boolean,
customapi: boolean,
videopath: string,
tvshowpath: string,
mediacentername: string,
password: string,
apipath: string,
videonr: number,
dbsize: number,
difftagnr: number,
tagsadded: number
}
interface props {}
/**
* Component for Generalsettings tag on Settingspage
* handles general settings of mediacenter which concerns to all pages
*/
class GeneralSettings extends React.Component {
constructor(props) {
class GeneralSettings extends React.Component<props, state> {
constructor(props: props) {
super(props);
this.state = {
passwordsupport: false,
tmdbsupport: null,
tmdbsupport: false,
customapi: false,
videopath: '',
tvshowpath: '',
mediacentername: '',
password: '',
apipath: '',
videonr: null,
dbsize: null,
difftagnr: null,
tagsadded: null
videonr: 0,
dbsize: 0,
difftagnr: 0,
tagsadded: 0
};
}
componentDidMount() {
componentDidMount(): void {
this.loadSettings();
}
render() {
render(): JSX.Element {
const themeStyle = GlobalInfos.getThemeStyle();
return (
<>
@@ -45,7 +69,7 @@ class GeneralSettings extends React.Component {
subtext='Videos in Gravity'
icon={faArchive}/>
<InfoHeaderItem backColor='yellow'
text={this.state.dbsize !== undefined ? this.state.dbsize + ' MB' : undefined}
text={this.state.dbsize !== undefined ? this.state.dbsize + ' MB' : ''}
subtext='Database size'
icon={faRulerVertical}/>
<InfoHeaderItem backColor='green'
@@ -58,7 +82,7 @@ class GeneralSettings extends React.Component {
icon={faBalanceScaleLeft}/>
</div>
<div className={style.GeneralForm + ' ' + themeStyle.subtextcolor}>
<Form data-testid='mainformsettings' onSubmit={(e) => {
<Form data-testid='mainformsettings' onSubmit={(e): void => {
e.preventDefault();
this.saveSettings();
}}>
@@ -66,24 +90,49 @@ class GeneralSettings extends React.Component {
<Form.Group as={Col} data-testid='videpathform'>
<Form.Label>Video Path</Form.Label>
<Form.Control type='text' placeholder='/var/www/html/video' value={this.state.videopath}
onChange={(ee) => this.setState({videopath: ee.target.value})}/>
onChange={(ee): void => this.setState({videopath: ee.target.value})}/>
</Form.Group>
<Form.Group as={Col} data-testid='tvshowpath'>
<Form.Label>TV Show Path</Form.Label>
<Form.Control type='text' placeholder='/var/www/html/tvshow'
value={this.state.tvshowpath}
onChange={(e) => this.setState({tvshowpath: e.target.value})}/>
onChange={(e): void => this.setState({tvshowpath: e.target.value})}/>
</Form.Group>
</Form.Row>
<Form.Check
type='switch'
id='custom-switch-api'
label='Use custom API url'
checked={this.state.customapi}
onChange={(): void => {
if (this.state.customapi) {
setCustomBackendDomain('');
}
this.setState({customapi: !this.state.customapi});
}}
/>
{this.state.customapi ?
<Form.Group className={style.customapiform} data-testid='apipath'>
<Form.Label>API Backend url</Form.Label>
<Form.Control type='text' placeholder='https://127.0.0.1'
value={this.state.apipath}
onChange={(e): void => {
this.setState({apipath: e.target.value});
setCustomBackendDomain(e.target.value);
}}/>
</Form.Group> : null}
<Form.Check
type='switch'
id='custom-switch'
data-testid='passwordswitch'
label='Enable Password support'
checked={this.state.passwordsupport}
onChange={() => {
onChange={(): void => {
this.setState({passwordsupport: !this.state.passwordsupport});
}}
/>
@@ -92,7 +141,7 @@ class GeneralSettings extends React.Component {
<Form.Group data-testid='passwordfield'>
<Form.Label>Password</Form.Label>
<Form.Control type='password' placeholder='**********' value={this.state.password}
onChange={(e) => this.setState({password: e.target.value})}/>
onChange={(e): void => this.setState({password: e.target.value})}/>
</Form.Group> : null
}
@@ -102,7 +151,7 @@ class GeneralSettings extends React.Component {
data-testid='tmdb-switch'
label='Enable TMDB video grabbing support'
checked={this.state.tmdbsupport}
onChange={() => {
onChange={(): void => {
this.setState({tmdbsupport: !this.state.tmdbsupport});
}}
/>
@@ -113,7 +162,7 @@ class GeneralSettings extends React.Component {
data-testid='darktheme-switch'
label='Enable Dark-Theme'
checked={GlobalInfos.isDarkTheme()}
onChange={() => {
onChange={(): void => {
GlobalInfos.enableDarkTheme(!GlobalInfos.isDarkTheme());
this.forceUpdate();
// todo initiate rerender
@@ -123,7 +172,7 @@ class GeneralSettings extends React.Component {
<Form.Group className={style.mediacenternameform} data-testid='nameform'>
<Form.Label>The name of the Mediacenter</Form.Label>
<Form.Control type='text' placeholder='Mediacentername' value={this.state.mediacentername}
onChange={(e) => this.setState({mediacentername: e.target.value})}/>
onChange={(e): void => this.setState({mediacentername: e.target.value})}/>
</Form.Group>
<Button variant='primary' type='submit'>
@@ -141,55 +190,45 @@ class GeneralSettings extends React.Component {
/**
* inital load of already specified settings from backend
*/
loadSettings() {
const updateRequest = new FormData();
updateRequest.append('action', 'loadGeneralSettings');
loadSettings(): void {
callAPI(APINode.Settings, {action: 'loadGeneralSettings'}, (result: SettingsTypes.loadGeneralSettingsType) => {
this.setState({
videopath: result.video_path,
tvshowpath: result.episode_path,
mediacentername: result.mediacenter_name,
password: result.password,
passwordsupport: result.passwordEnabled,
tmdbsupport: result.TMDB_grabbing,
fetch('/api/settings.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
console.log(result);
this.setState({
videopath: result.video_path,
tvshowpath: result.episode_path,
mediacentername: result.mediacenter_name,
password: result.password,
passwordsupport: result.passwordEnabled,
tmdbsupport: result.TMDB_grabbing,
videonr: result.videonr,
dbsize: result.dbsize,
difftagnr: result.difftagnr,
tagsadded: result.tagsadded
});
}));
videonr: result.videonr,
dbsize: result.dbsize,
difftagnr: result.difftagnr,
tagsadded: result.tagsadded
});
});
}
/**
* save the selected and typed settings to the backend
*/
saveSettings() {
const updateRequest = new FormData();
updateRequest.append('action', 'saveGeneralSettings');
updateRequest.append('password', this.state.passwordsupport ? this.state.password : '-1');
updateRequest.append('videopath', this.state.videopath);
updateRequest.append('tvshowpath', this.state.tvshowpath);
updateRequest.append('mediacentername', this.state.mediacentername);
updateRequest.append('tmdbsupport', this.state.tmdbsupport);
updateRequest.append('darkmodeenabled', GlobalInfos.isDarkTheme().toString());
fetch('/api/settings.php', {method: 'POST', body: updateRequest})
.then((response) => response.json()
.then((result) => {
if (result.success) {
console.log('successfully saved settings');
// todo 2020-07-10: popup success
} else {
console.log('failed to save settings');
// todo 2020-07-10: popup error
}
}));
saveSettings(): void {
callAPI(APINode.Settings, {
action: 'saveGeneralSettings',
password: this.state.passwordsupport ? this.state.password : '-1',
videopath: this.state.videopath,
tvshowpath: this.state.tvshowpath,
mediacentername: this.state.mediacentername,
tmdbsupport: this.state.tmdbsupport,
darkmodeenabled: GlobalInfos.isDarkTheme().toString()
}, (result: GeneralSuccess) => {
if (result.result) {
console.log('successfully saved settings');
// todo 2020-07-10: popup success
} else {
console.log('failed to save settings');
// todo 2020-07-10: popup error
}
});
}
}
-126
View File
@@ -1,126 +0,0 @@
import React from 'react';
import style from './MovieSettings.module.css';
/**
* Component for MovieSettings on Settingspage
* handles settings concerning to movies in general
*/
class MovieSettings extends React.Component {
constructor(props) {
super(props);
this.state = {
text: [],
startbtnDisabled: false
};
}
componentDidMount() {
this.myinterval = setInterval(this.updateStatus, 1000);
}
componentWillUnmount() {
clearInterval(this.myinterval);
}
render() {
return (
<>
<button disabled={this.state.startbtnDisabled}
className='btn btn-success'
onClick={() => {this.startReindex();}}>Reindex Movie
</button>
<button className='btn btn-warning'
onClick={() => {this.cleanupGravity();}}>Cleanup Gravity
</button>
<div className={style.indextextarea}>{this.state.text.map(m => (
<div className='textarea-element'>{m}</div>
))}</div>
</>
);
}
/**
* starts the reindex process of the videos in the specified folder
*/
startReindex() {
// clear output text before start
this.setState({text: []});
this.setState({startbtnDisabled: true});
console.log('starting');
const request = new FormData();
request.append('action', 'startReindex');
// fetch all videos available
fetch('/api/settings.php', {method: 'POST', body: request})
.then((response) => response.json()
.then((result) => {
console.log(result);
if (result.success) {
console.log('started successfully');
} else {
console.log('error, reindex already running');
this.setState({startbtnDisabled: true});
}
}))
.catch(() => {
console.log('no connection to backend');
});
if (this.myinterval) {
clearInterval(this.myinterval);
}
this.myinterval = setInterval(this.updateStatus, 1000);
}
/**
* This interval function reloads the current status of reindexing from backend
*/
updateStatus = () => {
const request = new FormData();
request.append('action', 'getStatusMessage');
fetch('/api/settings.php', {method: 'POST', body: request})
.then((response) => response.json()
.then((result) => {
if (result.contentAvailable === true) {
console.log(result);
// todo 2020-07-4: scroll to bottom of div here
this.setState({
// insert a string for each line
text: [...result.message.split('\n'),
...this.state.text]
});
} else {
// clear refresh interval if no content available
clearInterval(this.myinterval);
this.setState({startbtnDisabled: false});
}
}))
.catch(() => {
console.log('no connection to backend');
});
};
/**
* send request to cleanup db gravity
*/
cleanupGravity() {
const request = new FormData();
request.append('action', 'cleanupGravity');
fetch('/api/settings.php', {method: 'POST', body: request})
.then((response) => response.text()
.then((result) => {
this.setState({
text: ['successfully cleaned up gravity!']
});
}))
.catch(() => {
console.log('no connection to backend');
});
}
}
export default MovieSettings;
+51 -35
View File
@@ -1,6 +1,7 @@
import {shallow} from 'enzyme';
import React from 'react';
import MovieSettings from './MovieSettings';
import {callAPI} from "../../utils/Api";
describe('<MovieSettings/>', function () {
it('renders without crashing ', function () {
@@ -49,64 +50,79 @@ describe('<MovieSettings/>', function () {
});
});
it('content available received and in state', done => {
global.fetch = global.prepareFetchApi({
it('content available received and in state', () => {
const wrapper = shallow(<MovieSettings/>);
callAPIMock({
contentAvailable: true,
message: 'firstline\nsecondline'
});
const wrapper = shallow(<MovieSettings/>);
})
wrapper.instance().updateStatus();
process.nextTick(() => {
expect(wrapper.state()).toMatchObject({
text: [
'firstline',
'secondline'
]
});
global.fetch.mockClear();
done();
expect(wrapper.state()).toMatchObject({
text: [
'firstline',
'secondline'
]
});
});
it('test reindex with no content available', done => {
global.fetch = global.prepareFetchApi({
it('test reindex with no content available', () => {
callAPIMock({
contentAvailable: false
});
})
global.clearInterval = jest.fn();
const wrapper = shallow(<MovieSettings/>);
wrapper.instance().updateStatus();
process.nextTick(() => {
// expect the refresh interval to be cleared
expect(global.clearInterval).toBeCalledTimes(1);
// expect the refresh interval to be cleared
expect(global.clearInterval).toBeCalledTimes(1);
// expect startbtn to be reenabled
expect(wrapper.state()).toMatchObject({startbtnDisabled: false});
global.fetch.mockClear();
done();
});
// expect startbtn to be reenabled
expect(wrapper.state()).toMatchObject({startbtnDisabled: false});
});
it('test simulate gravity cleanup', done => {
global.fetch = global.prepareFetchApi('mmi');
it('test simulate gravity cleanup', () => {
// global.fetch = global.prepareFetchApi('mmi');
callAPIMock({})
const wrapper = shallow(<MovieSettings/>);
wrapper.instance().setState = jest.fn(),
wrapper.instance().setState = jest.fn();
wrapper.find('button').findWhere(e => e.text() === 'Cleanup Gravity' && e.type() === 'button').simulate('click');
wrapper.find('button').findWhere(e => e.text() === 'Cleanup Gravity' && e.type() === 'button').simulate('click');
// initial send of reindex request to server
expect(global.fetch).toBeCalledTimes(1);
expect(callAPI).toBeCalledTimes(1);
process.nextTick(() => {
expect(wrapper.instance().setState).toBeCalledTimes(1);
expect(wrapper.instance().setState).toBeCalledTimes(1);
});
global.fetch.mockClear();
done();
it('test no textDiv insertion if string is empty', function () {
const wrapper = shallow(<MovieSettings/>);
callAPIMock({
contentAvailable: true,
message: 'test'
})
wrapper.instance().updateStatus();
expect(wrapper.state()).toMatchObject({
text: ['test']
});
// expect an untouched state if we try to add an empty string...
callAPIMock({
contentAvailable: true,
message: ''
})
wrapper.instance().updateStatus();
expect(wrapper.state()).toMatchObject({
text: ['test']
});
});
});
+120
View File
@@ -0,0 +1,120 @@
import React from 'react';
import style from './MovieSettings.module.css';
import {APINode, callAPI} from '../../utils/Api';
import {GeneralSuccess} from '../../types/GeneralTypes';
import {SettingsTypes} from '../../types/ApiTypes';
interface state {
text: string[]
startbtnDisabled: boolean
}
interface props {}
/**
* Component for MovieSettings on Settingspage
* handles settings concerning to movies in general
*/
class MovieSettings extends React.Component<props, state> {
myinterval: number = -1;
constructor(props: props) {
super(props);
this.state = {
text: [],
startbtnDisabled: false
};
}
componentDidMount(): void {
this.myinterval = window.setInterval(this.updateStatus, 1000);
}
componentWillUnmount(): void {
if (this.myinterval !== -1)
clearInterval(this.myinterval);
}
render(): JSX.Element {
return (
<>
<button disabled={this.state.startbtnDisabled}
className='btn btn-success'
onClick={(): void => {this.startReindex();}}>Reindex Movie
</button>
<button className='btn btn-warning'
onClick={(): void => {this.cleanupGravity();}}>Cleanup Gravity
</button>
<div className={style.indextextarea}>{this.state.text.map(m => (
<div key={m} className='textarea-element'>{m}</div>
))}</div>
</>
);
}
/**
* starts the reindex process of the videos in the specified folder
*/
startReindex(): void {
// clear output text before start
this.setState({text: []});
this.setState({startbtnDisabled: true});
console.log('starting');
callAPI(APINode.Settings, {action: 'startReindex'}, (result: GeneralSuccess): void => {
console.log(result);
if (result.result === 'success') {
console.log('started successfully');
} else {
console.log('error, reindex already running');
this.setState({startbtnDisabled: true});
}
});
if (this.myinterval !== -1) {
clearInterval(this.myinterval);
}
this.myinterval = window.setInterval(this.updateStatus, 1000);
}
/**
* This interval function reloads the current status of reindexing from backend
*/
updateStatus = (): void => {
callAPI(APINode.Settings, {action: 'getStatusMessage'}, (result: SettingsTypes.getStatusMessageType) => {
if (result.contentAvailable) {
// ignore if message is empty
console.log(result);
if(result.message === '') return;
// todo 2020-07-4: scroll to bottom of div here
this.setState({
// insert a string for each line
text: [...result.message.split('\n'),
...this.state.text]
});
} else {
// clear refresh interval if no content available
clearInterval(this.myinterval);
this.setState({startbtnDisabled: false});
}
});
};
/**
* send request to cleanup db gravity
*/
cleanupGravity(): void {
callAPI(APINode.Settings, {action: 'cleanupGravity'}, (result) => {
this.setState({
text: ['successfully cleaned up gravity!']
});
});
}
}
export default MovieSettings;
-61
View File
@@ -1,61 +0,0 @@
import React from 'react';
import MovieSettings from './MovieSettings';
import GeneralSettings from './GeneralSettings';
import style from './SettingsPage.module.css';
import GlobalInfos from '../../GlobalInfos';
/**
* The Settingspage handles all kinds of settings for the mediacenter
* and is basically a wrapper for child-tabs
*/
class SettingsPage extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
currentpage: 'general'
};
}
/**
* load the selected tab
* @returns {JSX.Element|string} the jsx element of the selected tab
*/
getContent() {
switch (this.state.currentpage) {
case 'general':
return <GeneralSettings/>;
case 'movies':
return <MovieSettings/>;
case 'tv':
return <span/>; // todo this page
default:
return 'unknown button clicked';
}
}
render() {
const themestyle = GlobalInfos.getThemeStyle();
return (
<div>
<div className={style.SettingsSidebar + ' ' + themestyle.secbackground}>
<div className={style.SettingsSidebarTitle + ' ' + themestyle.lighttextcolor}>Settings</div>
<div onClick={() => this.setState({currentpage: 'general'})}
className={style.SettingSidebarElement}>General
</div>
<div onClick={() => this.setState({currentpage: 'movies'})}
className={style.SettingSidebarElement}>Movies
</div>
<div onClick={() => this.setState({currentpage: 'tv'})}
className={style.SettingSidebarElement}>TV Shows
</div>
</div>
<div className={style.SettingsContent}>
{this.getContent()}
</div>
</div>
);
}
}
export default SettingsPage;
@@ -26,6 +26,7 @@
.SettingSidebarElement {
background-color: #919fd9;
border-radius: 7px;
color: black;
font-weight: bold;
margin: 10px 5px 5px;
padding: 5px;
@@ -7,34 +7,4 @@ describe('<RandomPage/>', function () {
const wrapper = shallow(<SettingsPage/>);
wrapper.unmount();
});
it('simulate topic clicka', function () {
const wrapper = shallow(<SettingsPage/>);
simulateSideBarClick('General', wrapper);
expect(wrapper.state().currentpage).toBe('general');
expect(wrapper.find('.SettingsContent').find('GeneralSettings')).toHaveLength(1);
simulateSideBarClick('Movies', wrapper);
expect(wrapper.state().currentpage).toBe('movies');
expect(wrapper.find('.SettingsContent').find('MovieSettings')).toHaveLength(1);
simulateSideBarClick('TV Shows', wrapper);
expect(wrapper.state().currentpage).toBe('tv');
expect(wrapper.find('.SettingsContent').find('span')).toHaveLength(1);
});
function simulateSideBarClick(name, wrapper) {
wrapper.find('.SettingSidebarElement').findWhere(it =>
it.text() === name &&
it.type() === 'div').simulate('click');
}
it('simulate unknown topic', function () {
const wrapper = shallow(<SettingsPage/>);
wrapper.setState({currentpage: 'unknown'});
expect(wrapper.find('.SettingsContent').text()).toBe('unknown button clicked');
});
});
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
import MovieSettings from './MovieSettings';
import GeneralSettings from './GeneralSettings';
import style from './SettingsPage.module.css';
import GlobalInfos from '../../utils/GlobalInfos';
import {NavLink, Redirect, Route, Switch} from 'react-router-dom';
/**
* The Settingspage handles all kinds of settings for the mediacenter
* and is basically a wrapper for child-tabs
*/
class SettingsPage extends React.Component {
render(): JSX.Element {
const themestyle = GlobalInfos.getThemeStyle();
return (
<div>
<div className={style.SettingsSidebar + ' ' + themestyle.secbackground}>
<div className={style.SettingsSidebarTitle + ' ' + themestyle.lighttextcolor}>Settings</div>
<NavLink to='/settings/general'>
<div className={style.SettingSidebarElement}>General</div>
</NavLink>
<NavLink to='/settings/movies'>
<div className={style.SettingSidebarElement}>Movies</div>
</NavLink>
<NavLink to='/settings/tv'>
<div className={style.SettingSidebarElement}>TV Shows</div>
</NavLink>
</div>
<div className={style.SettingsContent}>
<Switch>
<Route path="/settings/general">
<GeneralSettings/>
</Route>
<Route path="/settings/movies">
<MovieSettings/>
</Route>
<Route path="/settings/tv">
<span/>
</Route>
<Route path="/settings">
<Redirect to='/settings/general'/>
</Route>
</Switch>
</div>
</div>
);
}
}
export default SettingsPage;
+32
View File
@@ -6,6 +6,7 @@ import '@testing-library/jest-dom/extend-expect';
import {configure} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import GlobalInfos from './utils/GlobalInfos';
configure({adapter: new Adapter()});
@@ -31,3 +32,34 @@ global.prepareFailingFetchApi = () => {
const mockFetchPromise = Promise.reject('myreason');
return (jest.fn().mockImplementation(() => mockFetchPromise));
};
/**
* prepares a viewbinding instance
* @param func a mock function to be called
*/
global.prepareViewBinding = (func) => {
GlobalInfos.getViewBinding = () => {
return {
changeRootElement: func,
returnToLastElement: func
};
};
};
global.callAPIMock = (resonse) => {
const helpers = require('./utils/Api');
helpers.callAPI = jest.fn().mockImplementation((_, __, func1) => {func1(resonse);});
};
// code to run before each test
global.beforeEach(() => {
// empty fetch response implementation for each test
global.fetch = prepareFetchApi({});
// todo with callAPIMock
});
global.afterEach(() => {
// clear all mocks after each test
jest.resetAllMocks();
});
+66
View File
@@ -0,0 +1,66 @@
import {ActorType, TagType} from './VideoTypes';
export namespace VideoTypes {
export interface loadVideoType {
movie_url: string
thumbnail: string
movie_id: number
movie_name: string
likes: number
quality: number
length: number
tags: TagType[]
suggesttag: TagType[]
actors: ActorType[]
}
export interface startDataType {
total: number;
fullhd: number;
hd: number;
sd: number;
tags: number;
}
export interface VideoUnloadedType {
movie_id: number;
movie_name: string
}
}
export namespace SettingsTypes {
export interface initialApiCallData {
DarkMode: boolean;
passwordEnabled: boolean;
mediacenter_name: string;
}
export interface loadGeneralSettingsType {
video_path: string,
episode_path: string,
mediacenter_name: string,
password: string,
passwordEnabled: boolean,
TMDB_grabbing: boolean,
videonr: number,
dbsize: number,
difftagnr: number,
tagsadded: number
}
export interface getStatusMessageType {
contentAvailable: boolean;
message: string;
}
}
export namespace ActorTypes {
/**
* result of actor fetch
*/
export interface videofetchresult {
videos: VideoTypes.VideoUnloadedType[];
info: ActorType;
}
}
+16
View File
@@ -0,0 +1,16 @@
import {TagType} from './VideoTypes';
export interface GeneralSuccess {
result: string
}
interface TagarrayType {
[_: string]: TagType
}
export const DefaultTags: TagarrayType = {
all: {tag_id: 1, tag_name: 'all'},
fullhd: {tag_id: 2, tag_name: 'fullhd'},
lowq: {tag_id: 3, tag_name: 'lowquality'},
hd: {tag_id: 4, tag_name: 'hd'}
};
+13
View File
@@ -0,0 +1,13 @@
/**
* type accepted by Tag component
*/
export interface TagType {
tag_name: string
tag_id: number
}
export interface ActorType {
thumbnail: string;
name: string;
actor_id: number;
}
+97
View File
@@ -0,0 +1,97 @@
let customBackendURL: string;
/**
* get the domain of the api backend
* @return string domain of backend http://x.x.x.x/bla
*/
export function getBackendDomain(): string {
let userAgent = navigator.userAgent.toLowerCase();
if (userAgent.indexOf(' electron/') > -1) {
// Electron-specific code - force a custom backendurl
return (customBackendURL);
} else {
// use custom only if defined
if (customBackendURL) {
return (customBackendURL);
} else {
return (window.location.origin);
}
}
}
/**
* set a custom backend domain
* @param domain a url in format [http://x.x.x.x/somanode]
*/
export function setCustomBackendDomain(domain: string): void {
customBackendURL = domain;
}
/**
* a helper function to get the api path
*/
function getAPIDomain(): string {
return getBackendDomain() + '/api/';
}
/**
* interface how an api request should look like
*/
interface ApiBaseRequest {
action: string | number,
[_: string]: string | number | boolean
}
/**
* helper function to build a formdata for requesting post data correctly
* @param args api request object
*/
function buildFormData(args: ApiBaseRequest): FormData {
const req = new FormData();
for (const i in args) {
req.append(i, (args[i].toString()));
}
return req;
}
/**
* A backend api call
* @param apinode which api backend handler to call
* @param fd the object to send to backend
* @param callback the callback with json reply from backend
* @param errorcallback a optional callback if an error occured
*/
export function callAPI<T>(apinode: APINode, fd: ApiBaseRequest, callback: (_: T) => void, errorcallback: (_: string) => void = (_: string): void => {}): void {
fetch(getAPIDomain() + apinode, {method: 'POST', body: buildFormData(fd)})
.then((response) => response.json()
.then((result) => {
callback(result);
})).catch(reason => errorcallback(reason));
}
/**
* A backend api call
* @param apinode which api backend handler to call
* @param fd the object to send to backend
* @param callback the callback with PLAIN text reply from backend
*/
export function callAPIPlain(apinode: APINode, fd: ApiBaseRequest, callback: (_: string) => void): void {
fetch(getAPIDomain() + apinode, {method: 'POST', body: buildFormData(fd)})
.then((response) => response.text()
.then((result) => {
callback(result);
}));
}
/**
* API nodes definitions
*/
export enum APINode {
Settings = 'settings.php',
Tags = 'tags.php',
Actor = 'actor.php',
Video = 'video.php'
}
@@ -1,5 +1,5 @@
import darktheme from './AppDarkTheme.module.css';
import lighttheme from './AppLightTheme.module.css';
import darktheme from '../AppDarkTheme.module.css';
import lighttheme from '../AppLightTheme.module.css';
/**
* This class is available for all components in project
File renamed without changes.
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more