use flexbox to wrap settings tiles correctly

new icon for different tags
ignore test files in codeclimate test
This commit is contained in:
Lukas Heiligenbrunner 2020-10-19 21:12:07 +00:00
parent b21d2a29cc
commit 28f3d6db70
22 changed files with 307 additions and 55 deletions

View File

@ -1,4 +1,43 @@
version: "2" version: "2"
plugins:
csslint:
enabled: true
coffeelint:
enabled: true
duplication:
enabled: true
config:
languages:
- ruby
- javascript
- python
- php
eslint:
enabled: true
channel: __ESLINT_CHANNEL__
fixme:
enabled: true
rubocop:
enabled: true
exclude_patterns:
- config/
- db/
- dist/
- features/
- "**/node_modules/"
- script/
- "**/spec/"
- "**/test/"
- "**/tests/"
- Tests/
- "**/vendor/"
- "**/*_test.go"
- "**/*.d.ts"
- "**/*.min.js"
- "**/*.min.css"
- "**/__tests__/"
- "**/__mocks__/"
- "**/*.test.js"
checks: checks:
argument-count: argument-count:
config: config:

View File

@ -21,6 +21,7 @@ prepare:
stage: prepare stage: prepare
script: script:
- npm install --progress=false - npm install --progress=false
- npm update --progress=false
build: build:
stage: build stage: build

View File

@ -14,12 +14,35 @@ class Settings extends RequestBase {
/** /**
* handle settings stuff to load from db * handle settings stuff to load from db
*/ */
private function getFromDB(){ private function getFromDB() {
/** /**
* load currently set settings form db for init of settings page * load currently set settings form db for init of settings page
*/ */
$this->addActionHandler("loadGeneralSettings", function () { $this->addActionHandler("loadGeneralSettings", function () {
$query = "SELECT * from settings"; // query settings and infotile values
$query = "
SELECT (
SELECT COUNT(*)
FROM videos
) AS videonr,
(
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS Size
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = '" . Database::getInstance()->getDatabaseName() . "'
GROUP BY table_schema
) AS dbsize,
(
SELECT COUNT(*)
FROM tags
) AS difftagnr,
(
SELECT COUNT(*)
FROM video_tags
) AS tagsadded,
settings.*
FROM settings
LIMIT 1
";
$result = $this->conn->query($query); $result = $this->conn->query($query);
@ -51,7 +74,7 @@ class Settings extends RequestBase {
/** /**
* handle setting stuff to save to db * handle setting stuff to save to db
*/ */
private function saveToDB(){ private function saveToDB() {
/** /**
* save changed settings to db * save changed settings to db
*/ */

View File

@ -3,12 +3,16 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"bootstrap": "^4.5.0", "@fortawesome/fontawesome-svg-core": "^1.2.30",
"@fortawesome/free-regular-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/react-fontawesome": "^0.1.11",
"bootstrap": "^4.5.3",
"plyr-react": "^2.2.0", "plyr-react": "^2.2.0",
"react": "^16.13.1", "react": "^16.14.0",
"react-bootstrap": "^1.0.1", "react-bootstrap": "^1.3.0",
"react-dom": "^16.13.1", "react-dom": "^16.14.0",
"react-scripts": "^3.4.1" "react-scripts": "^3.4.3"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
@ -49,7 +53,7 @@
"@testing-library/react": "^9.5.0", "@testing-library/react": "^9.5.0",
"@testing-library/user-event": "^7.2.1", "@testing-library/user-event": "^7.2.1",
"enzyme": "^3.11.0", "enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2", "enzyme-adapter-react-16": "^1.15.5",
"jest-junit": "^10.0.0" "jest-junit": "^10.0.0"
} }
} }

View File

@ -37,9 +37,8 @@
} }
.navcontainer { .navcontainer {
border-bottom-width: 2px; border-width: 0 0 2px 0;
border-style: dotted; border-style: dotted;
border-width: 0;
padding-bottom: 40px; padding-bottom: 40px;
padding-top: 20px; padding-top: 20px;

View File

@ -39,7 +39,7 @@ describe('<AddTagPopup/>', function () {
global.fetch = prepareFetchApi({result: "success"}); global.fetch = prepareFetchApi({result: "success"});
wrapper.setProps({ wrapper.setProps({
submit: jest.fn((arg1, arg2) => {}), submit: jest.fn(() => {}),
onHide: jest.fn() onHide: jest.fn()
}, () => { }, () => {
wrapper.instance().addTag(1, "test"); wrapper.instance().addTag(1, "test");
@ -62,7 +62,7 @@ describe('<AddTagPopup/>', function () {
global.fetch = prepareFetchApi({result: "fail"}); global.fetch = prepareFetchApi({result: "fail"});
wrapper.setProps({ wrapper.setProps({
submit: jest.fn((arg1, arg2) => {}), submit: jest.fn(() => {}),
onHide: jest.fn() onHide: jest.fn()
}, () => { }, () => {
wrapper.instance().addTag(1, "test"); wrapper.instance().addTag(1, "test");

View File

@ -0,0 +1,34 @@
import React from "react";
import style from './InfoHeaderItem.module.css';
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {Spinner} from "react-bootstrap";
/**
* a component to display one of the short quickinfo tiles on dashboard
*/
class InfoHeaderItem extends React.Component {
render() {
return (
<div onClick={() => {
// call clicklistener if defined
if (this.props.onClick != null) this.props.onClick();
}} className={style.infoheaderitem} style={{backgroundColor: this.props.backColor}}>
<div className={style.icon}>
<FontAwesomeIcon style={{
verticalAlign: "middle",
lineHeight: "130px"
}} icon={this.props.icon} size='5x'/>
</div>
{this.props.text !== null && this.props.text !== undefined ?
<>
<div className={style.maintext}>{this.props.text}</div>
<div className={style.subtext}>{this.props.subtext}</div>
</>
: <span className={style.loadAnimation}><Spinner animation='border'/></span>
}
</div>
);
}
}
export default InfoHeaderItem;

View File

@ -0,0 +1,58 @@
/* styling for tile */
.infoheaderitem {
background-color: lightblue;
border-radius: 5px;
flex: calc(25% - 10px);
margin: 5px;
}
/* On screens that are 1317px wide or less, go from four columns to two columns */
@media screen and (max-width: 1317px) {
.infoheaderitem {
flex: calc(50% - 10px);
}
}
/* change opacity of icon when hovering whole tile */
.infoheaderitem:hover .icon {
opacity: 0.75;
transition: opacity linear 0.4s;
}
/* change cursor on hover */
.infoheaderitem:hover {
cursor: pointer;
}
.icon {
float: left;
height: 130px;
margin-left: 5px;
margin-right: 17px;
margin-top: 20px;
opacity: 0.5;
text-align: center;
width: 30%;
}
/* big main text in tile */
.maintext {
font-size: x-large;
font-weight: bold;
margin-top: 30px;
}
/* small subtext in tile */
.subtext {
font-size: large;
margin-top: 5px;
opacity: 0.7;
}
.loadAnimation {
display: inline-block;
line-height: 145px;
margin-left: calc(25% - 15px);
vertical-align: middle;
}

View File

@ -0,0 +1,43 @@
import {shallow} from "enzyme";
import React from "react";
import InfoHeaderItem from "./InfoHeaderItem";
describe('<InfoHeaderItem/>', function () {
it('renders without crashing ', function () {
const wrapper = shallow(<InfoHeaderItem/>);
wrapper.unmount();
});
it('renders correct text', function () {
const wrapper = shallow(<InfoHeaderItem text='mytext'/>);
expect(wrapper.find(".maintext").text()).toBe("mytext");
});
it('renders correct subtext', function () {
const wrapper = shallow(<InfoHeaderItem text='mimi' subtext='testtext'/>);
expect(wrapper.find(".subtext").text()).toBe("testtext");
});
it('test no subtext if no text defined', function () {
const wrapper = shallow(<InfoHeaderItem subtext='testi'/>);
expect(wrapper.find(".subtext")).toHaveLength(0);
});
it('test custom click handler', function () {
const func = jest.fn();
const wrapper = shallow(<InfoHeaderItem onClick={() => func()}/>);
expect(func).toBeCalledTimes(0);
wrapper.simulate("click");
expect(func).toBeCalledTimes(1);
});
it('test insertion of loading spinner', function () {
const wrapper = shallow(<InfoHeaderItem text={null}/>);
expect(wrapper.find("Spinner").length).toBe(1);
});
it('test loading spinner if undefined', function () {
const wrapper = shallow(<InfoHeaderItem/>);
expect(wrapper.find("Spinner").length).toBe(1);
});
});

View File

@ -18,21 +18,21 @@ class NewTagPopup extends React.Component {
<Modal <Modal
show={this.props.show} show={this.props.show}
onHide={this.props.onHide} onHide={this.props.onHide}
size="lg" size='lg'
aria-labelledby="contained-modal-title-vcenter" aria-labelledby='contained-modal-title-vcenter'
centered> centered>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title id="contained-modal-title-vcenter"> <Modal.Title id='contained-modal-title-vcenter'>
Create a new Tag! Create a new Tag!
</Modal.Title> </Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<Form.Group> <Form.Group>
<Form.Label>Tag Name:</Form.Label> <Form.Label>Tag Name:</Form.Label>
<Form.Control id='namefield' type="text" placeholder="Enter Tag name" onChange={(v) => { <Form.Control id='namefield' type='text' placeholder='Enter Tag name' onChange={(v) => {
this.value = v.target.value this.value = v.target.value
}}/> }}/>
<Form.Text className="text-muted"> <Form.Text className='text-muted'>
This Tag will automatically show up on category page. This Tag will automatically show up on category page.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>

View File

@ -48,7 +48,7 @@ class Preview extends React.Component {
<img className={style.previewimage} <img className={style.previewimage}
src={this.state.previewpicture} src={this.state.previewpicture}
alt='Pic loading.'/> : alt='Pic loading.'/> :
<span className={style.loadAnimation}><Spinner animation="border"/></span>} <span className={style.loadAnimation}><Spinner animation='border'/></span>}
</div> </div>
<div className={style.previewbottom}> <div className={style.previewbottom}>

View File

@ -10,7 +10,7 @@ class Tag extends React.Component {
render() { render() {
return ( return (
<button className={styles.tagbtn} onClick={() => this.TagClick()} <button className={styles.tagbtn} onClick={() => this.TagClick()}
data-testid="Test-Tag">{this.props.children}</button> data-testid='Test-Tag'>{this.props.children}</button>
); );
} }

View File

@ -84,7 +84,7 @@ class CategoryPage extends React.Component {
<VideoContainer <VideoContainer
data={this.videodata} data={this.videodata}
viewbinding={this.props.viewbinding}/> : null} viewbinding={this.props.viewbinding}/> : null}
<button data-testid='backbtn' className="btn btn-success" <button data-testid='backbtn' className='btn btn-success'
onClick={this.loadCategoryPageDefault}>Back onClick={this.loadCategoryPageDefault}>Back
</button> </button>
</> : </> :

View File

@ -98,7 +98,7 @@ describe('<CategoryPage/>', function () {
const func = jest.fn(); const func = jest.fn();
CategoryPage.prototype.fetchVideoData = func; CategoryPage.prototype.fetchVideoData = func;
shallow(<CategoryPage category="fullhd"/>); shallow(<CategoryPage category='fullhd'/>);
expect(func).toBeCalledTimes(1); expect(func).toBeCalledTimes(1);
}); });
@ -106,7 +106,7 @@ describe('<CategoryPage/>', function () {
it('test sidebar tag clicks', function () { it('test sidebar tag clicks', function () {
const func = jest.fn(); const func = jest.fn();
const wrapper = mount(<CategoryPage category="fullhd"/>); const wrapper = mount(<CategoryPage category='fullhd'/>);
wrapper.instance().loadTag = func; wrapper.instance().loadTag = func;
console.log(wrapper.debug()); console.log(wrapper.debug());

View File

@ -131,12 +131,12 @@ class HomePage extends React.Component {
e.preventDefault(); e.preventDefault();
this.searchVideos(this.keyword); this.searchVideos(this.keyword);
}}> }}>
<input data-testid='searchtextfield' className="form-control mr-sm-2" <input data-testid='searchtextfield' className='form-control mr-sm-2'
type="text" placeholder="Search" type='text' placeholder='Search'
onChange={(e) => { onChange={(e) => {
this.keyword = e.target.value this.keyword = e.target.value
}}/> }}/>
<button data-testid='searchbtnsubmit' className="btn btn-success" type="submit">Search</button> <button data-testid='searchbtnsubmit' className='btn btn-success' type='submit'>Search</button>
</form> </form>
</PageTitle> </PageTitle>
<SideBar> <SideBar>

View File

@ -126,7 +126,7 @@ class Player extends React.Component {
<SideBarItem><b>{this.state.quality}p</b> Quality!</SideBarItem> : null} <SideBarItem><b>{this.state.quality}p</b> Quality!</SideBarItem> : null}
{this.state.length !== 0 ? {this.state.length !== 0 ?
<SideBarItem><b>{Math.round(this.state.length / 60)}</b> Minutes of <SideBarItem><b>{Math.round(this.state.length / 60)}</b> Minutes of
length!</SideBarItem> : null} length!</SideBarItem> : null}
<Line/> <Line/>
<SideBarTitle>Tags:</SideBarTitle> <SideBarTitle>Tags:</SideBarTitle>
{this.state.tags.map((m) => ( {this.state.tags.map((m) => (
@ -167,8 +167,10 @@ class Player extends React.Component {
<div>not loaded yet</div>} <div>not loaded yet</div>}
<div className={style.videoactions}> <div className={style.videoactions}>
<button className='btn btn-primary' onClick={() => this.likebtn()}>Like this Video!</button> <button className='btn btn-primary' onClick={() => this.likebtn()}>Like this Video!</button>
<button className='btn btn-info' onClick={() => this.setState({popupvisible: true})}>Give this Video a Tag</button> <button className='btn btn-info' onClick={() => this.setState({popupvisible: true})}>Give this
<button className='btn btn-danger' onClick={() =>{this.deleteVideo()}}>Delete Video</button> Video a Tag
</button>
<button className='btn btn-danger' onClick={() => {this.deleteVideo()}}>Delete Video</button>
</div> </div>
</div> </div>
<button className={style.closebutton} onClick={() => this.closebtn()}>Close</button> <button className={style.closebutton} onClick={() => this.closebtn()}>Close</button>

View File

@ -12,7 +12,6 @@
display: block; display: block;
float: left; float: left;
margin-left: 20px; margin-left: 20px;
margin-top: 25px;
margin-top: 20px; margin-top: 20px;
width: 60%; width: 60%;
} }

View File

@ -25,9 +25,8 @@ class RandomPage extends React.Component {
render() { render() {
return ( return (
<div> <div>
<PageTitle <PageTitle title='Random Videos'
title='Random Videos' subtitle='4pc'/>
subtitle='4pc'/>
<SideBar> <SideBar>
<SideBarTitle>Visible Tags:</SideBarTitle> <SideBarTitle>Visible Tags:</SideBarTitle>

View File

@ -2,6 +2,9 @@ import React from "react";
import {Button, Col, Form} from "react-bootstrap"; import {Button, Col, Form} from "react-bootstrap";
import style from "./GeneralSettings.module.css" import style from "./GeneralSettings.module.css"
import GlobalInfos from "../../GlobalInfos"; import GlobalInfos from "../../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";
/** /**
* Component for Generalsettings tag on Settingspage * Component for Generalsettings tag on Settingspage
@ -18,7 +21,12 @@ class GeneralSettings extends React.Component {
videopath: "", videopath: "",
tvshowpath: "", tvshowpath: "",
mediacentername: "", mediacentername: "",
password: "" password: "",
videonr: null,
dbsize: null,
difftagnr: null,
tagsadded: null
}; };
} }
@ -30,31 +38,49 @@ class GeneralSettings extends React.Component {
const themeStyle = GlobalInfos.getThemeStyle(); const themeStyle = GlobalInfos.getThemeStyle();
return ( return (
<> <>
<div className={style.infoheader}>
<InfoHeaderItem backColor='lightblue'
text={this.state.videonr}
subtext='Videos in Gravity'
icon={faArchive}/>
<InfoHeaderItem backColor='yellow'
text={this.state.dbsize !== undefined ? this.state.dbsize + " MB" : undefined}
subtext='Database size'
icon={faRulerVertical}/>
<InfoHeaderItem backColor='green'
text={this.state.difftagnr}
subtext='different Tags'
icon={faAddressCard}/>
<InfoHeaderItem backColor='orange'
text={this.state.tagsadded}
subtext='tags added'
icon={faBalanceScaleLeft}/>
</div>
<div className={style.GeneralForm + ' ' + themeStyle.subtextcolor}> <div className={style.GeneralForm + ' ' + themeStyle.subtextcolor}>
<Form data-testid='mainformsettings' onSubmit={(e) => { <Form data-testid='mainformsettings' onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
this.saveSettings(); this.saveSettings();
}}> }}>
<Form.Row> <Form.Row>
<Form.Group as={Col} data-testid="videpathform"> <Form.Group as={Col} data-testid='videpathform'>
<Form.Label>Video Path</Form.Label> <Form.Label>Video Path</Form.Label>
<Form.Control type="text" placeholder="/var/www/html/video" value={this.state.videopath} <Form.Control type='text' placeholder='/var/www/html/video' value={this.state.videopath}
onChange={(ee) => this.setState({videopath: ee.target.value})}/> onChange={(ee) => this.setState({videopath: ee.target.value})}/>
</Form.Group> </Form.Group>
<Form.Group as={Col} data-testid="tvshowpath"> <Form.Group as={Col} data-testid='tvshowpath'>
<Form.Label>TV Show Path</Form.Label> <Form.Label>TV Show Path</Form.Label>
<Form.Control type='text' placeholder="/var/www/html/tvshow" <Form.Control type='text' placeholder='/var/www/html/tvshow'
value={this.state.tvshowpath} value={this.state.tvshowpath}
onChange={(e) => this.setState({tvshowpath: e.target.value})}/> onChange={(e) => this.setState({tvshowpath: e.target.value})}/>
</Form.Group> </Form.Group>
</Form.Row> </Form.Row>
<Form.Check <Form.Check
type="switch" type='switch'
id="custom-switch" id='custom-switch'
data-testid='passwordswitch' data-testid='passwordswitch'
label="Enable Password support" label='Enable Password support'
checked={this.state.passwordsupport} checked={this.state.passwordsupport}
onChange={() => { onChange={() => {
this.setState({passwordsupport: !this.state.passwordsupport}) this.setState({passwordsupport: !this.state.passwordsupport})
@ -62,18 +88,18 @@ class GeneralSettings extends React.Component {
/> />
{this.state.passwordsupport ? {this.state.passwordsupport ?
<Form.Group data-testid="passwordfield"> <Form.Group data-testid='passwordfield'>
<Form.Label>Password</Form.Label> <Form.Label>Password</Form.Label>
<Form.Control type="password" placeholder="**********" value={this.state.password} <Form.Control type='password' placeholder='**********' value={this.state.password}
onChange={(e) => this.setState({password: e.target.value})}/> onChange={(e) => this.setState({password: e.target.value})}/>
</Form.Group> : null </Form.Group> : null
} }
<Form.Check <Form.Check
type="switch" type='switch'
id="custom-switch-2" id='custom-switch-2'
data-testid='tmdb-switch' data-testid='tmdb-switch'
label="Enable TMDB video grabbing support" label='Enable TMDB video grabbing support'
checked={this.state.tmdbsupport} checked={this.state.tmdbsupport}
onChange={() => { onChange={() => {
this.setState({tmdbsupport: !this.state.tmdbsupport}) this.setState({tmdbsupport: !this.state.tmdbsupport})
@ -81,10 +107,10 @@ class GeneralSettings extends React.Component {
/> />
<Form.Check <Form.Check
type="switch" type='switch'
id="custom-switch-3" id='custom-switch-3'
data-testid='darktheme-switch' data-testid='darktheme-switch'
label="Enable Dark-Theme" label='Enable Dark-Theme'
checked={GlobalInfos.isDarkTheme()} checked={GlobalInfos.isDarkTheme()}
onChange={() => { onChange={() => {
GlobalInfos.enableDarkTheme(!GlobalInfos.isDarkTheme()); GlobalInfos.enableDarkTheme(!GlobalInfos.isDarkTheme());
@ -93,13 +119,13 @@ class GeneralSettings extends React.Component {
}} }}
/> />
<Form.Group className={style.mediacenternameform} data-testid="nameform"> <Form.Group className={style.mediacenternameform} data-testid='nameform'>
<Form.Label>The name of the Mediacenter</Form.Label> <Form.Label>The name of the Mediacenter</Form.Label>
<Form.Control type="text" placeholder="Mediacentername" value={this.state.mediacentername} <Form.Control type='text' placeholder='Mediacentername' value={this.state.mediacentername}
onChange={(e) => this.setState({mediacentername: e.target.value})}/> onChange={(e) => this.setState({mediacentername: e.target.value})}/>
</Form.Group> </Form.Group>
<Button variant="primary" type="submit"> <Button variant='primary' type='submit'>
Submit Submit
</Button> </Button>
</Form> </Form>
@ -125,7 +151,12 @@ class GeneralSettings extends React.Component {
mediacentername: result.mediacenter_name, mediacentername: result.mediacenter_name,
password: result.password, password: result.password,
passwordsupport: result.passwordEnabled, passwordsupport: result.passwordEnabled,
tmdbsupport: result.TMDB_grabbing tmdbsupport: result.TMDB_grabbing,
videonr: result.videonr,
dbsize: result.dbsize,
difftagnr: result.difftagnr,
tagsadded: result.tagsadded
}); });
})); }));
} }
@ -142,7 +173,7 @@ class GeneralSettings extends React.Component {
updateRequest.append('tvshowpath', this.state.tvshowpath); updateRequest.append('tvshowpath', this.state.tvshowpath);
updateRequest.append('mediacentername', this.state.mediacentername); updateRequest.append('mediacentername', this.state.mediacentername);
updateRequest.append("tmdbsupport", this.state.tmdbsupport); updateRequest.append("tmdbsupport", this.state.tmdbsupport);
updateRequest.append("darkmodeenabled", GlobalInfos.isDarkTheme()); updateRequest.append("darkmodeenabled", GlobalInfos.isDarkTheme().toString());
fetch('/api/settings.php', {method: 'POST', body: updateRequest}) fetch('/api/settings.php', {method: 'POST', body: updateRequest})
.then((response) => response.json() .then((response) => response.json()

View File

@ -1,4 +1,5 @@
.GeneralForm { .GeneralForm {
margin-top: 55px;
width: 60%; width: 60%;
} }
@ -6,3 +7,16 @@
margin-top: 25px; margin-top: 25px;
width: 40%; width: 40%;
} }
.infoheader {
display: flex;
flex-wrap: wrap;
}
/* On screens that are 722px wide or less, make the columns stack on top of each other instead of next to each other */
@media screen and (max-width: 722px) {
.infoheader {
flex-direction: column;
}
}

View File

@ -109,4 +109,9 @@ describe('<GeneralSettings/>', function () {
wrapper.find("[data-testid='tmdb-switch']").simulate("change"); wrapper.find("[data-testid='tmdb-switch']").simulate("change");
expect(wrapper.state().tmdbsupport).toBe(false); expect(wrapper.state().tmdbsupport).toBe(false);
}); });
it('test insertion of 4 infoheaderitems', function () {
const wrapper = shallow(<GeneralSettings/>);
expect(wrapper.find("InfoHeaderItem").length).toBe(4);
});
}); });

View File

@ -2,7 +2,8 @@
border-bottom-right-radius: 10px; border-bottom-right-radius: 10px;
border-top-right-radius: 10px; border-top-right-radius: 10px;
float: left; float: left;
min-height: calc(100vh - 62px); margin-top: 10px;
min-height: calc(100vh - 70px);
min-width: 110px; min-width: 110px;
padding-top: 20px; padding-top: 20px;
width: 10%; width: 10%;