Begin some React Toolbox UI
parent
d1972049ae
commit
101e95599e
|
@ -9,3 +9,4 @@ dist/
|
|||
# Temporary files
|
||||
oid_settings.json
|
||||
nginx.conf
|
||||
sample_data.sql
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default class Button extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<button className={`btn btn-${this.props.color}`} onClick={this.props.onClick}>
|
||||
{ this.props.children }
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Button.propTypes = {
|
||||
children: React.PropTypes.node.isRequired,
|
||||
onClick: React.PropTypes.func,
|
||||
color: React.PropTypes.string,
|
||||
};
|
||||
|
||||
Button.defaultProps = {
|
||||
onClick: () => {},
|
||||
color: 'primary',
|
||||
};
|
|
@ -1,27 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default class Input extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<input
|
||||
type={this.props.type}
|
||||
placeholder={this.props.placeholder}
|
||||
className="form-control"
|
||||
value={this.props.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Input.propTypes = {
|
||||
type: React.PropTypes.string,
|
||||
placeholder: React.PropTypes.string,
|
||||
value: React.PropTypes.string,
|
||||
};
|
||||
|
||||
Input.defaultProps = {
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
value: '',
|
||||
};
|
|
@ -1,38 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default class Column extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.className = this.className.bind(this);
|
||||
}
|
||||
|
||||
className() {
|
||||
if (this.props.width) {
|
||||
if (this.props.breakpoint) {
|
||||
return `col-${this.props.breakpoint}-${this.props.width}`;
|
||||
}
|
||||
return `col-${this.props.width}`;
|
||||
}
|
||||
return 'col';
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={this.className()}>
|
||||
{ this.props.children }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Column.propTypes = {
|
||||
children: React.PropTypes.node.isRequired,
|
||||
width: React.PropTypes.number,
|
||||
breakpoint: React.PropTypes.string,
|
||||
};
|
||||
|
||||
Column.defaultProps = {
|
||||
width: null,
|
||||
breakpoint: null,
|
||||
};
|
|
@ -1,16 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default class Container extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="container">
|
||||
{ this.props.children }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Container.propTypes = {
|
||||
children: React.PropTypes.node.isRequired,
|
||||
};
|
|
@ -1,17 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default class Row extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="row">
|
||||
{ this.props.children }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Row.propTypes = {
|
||||
children: React.PropTypes.node.isRequired,
|
||||
};
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta charset="UTF-8" />
|
||||
<title>Chronos</title>
|
||||
<link rel="stylesheet" href="assets/bootstrap/dist/css/bootstrap.min.css" />
|
||||
<link href="//fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||
<link href="//fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet" />
|
||||
<style>*, *:before, *:after { box-sizing: border-box; } html, body { margin: 0; padding: 0; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
|
||||
</div>
|
||||
<script src="assets/oidc-client/dist/oidc-client.min.js"></script>
|
||||
<script src="assets/bootstrap/dist/js/bootstrap.min.js"></script>
|
||||
<script src="bundle.js"></script>
|
||||
<script src="/bundle.js" async></script>
|
||||
</body>
|
||||
|
|
|
@ -1,18 +1,24 @@
|
|||
import 'babel-polyfill';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter as Router, Route } from 'react-router-dom';
|
||||
|
||||
import Navigation from './navigation';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import * as oidc from 'oidc-client';
|
||||
|
||||
import LayoutMain from './layouts/main';
|
||||
import PageHome from './pages/home';
|
||||
import PageLogin from './pages/login';
|
||||
import PageMain from './pages/main';
|
||||
import PageLoginSchool from './pages/login_school';
|
||||
|
||||
ReactDOM.render(
|
||||
<Router>
|
||||
<div>
|
||||
<Navigation />
|
||||
<Route exact path="/" component={PageMain} />
|
||||
<LayoutMain>
|
||||
<Route exact path="/" component={PageHome} />
|
||||
<Route path="/login" component={PageLogin} />
|
||||
</div>
|
||||
<Route path="/login/:id" component={PageLoginSchool} />
|
||||
</LayoutMain>
|
||||
</Router>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Layout, NavDrawer, Panel, AppBar, Navigation, Link, List, ListItem } from 'react-toolbox';
|
||||
|
||||
export default class LayoutMain extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
drawerActive: false,
|
||||
};
|
||||
|
||||
this.toggleDrawerActive = this.toggleDrawerActive.bind(this);
|
||||
}
|
||||
|
||||
toggleDrawerActive() {
|
||||
this.setState({
|
||||
drawerActive: !this.state.drawerActive,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Layout>
|
||||
<NavDrawer
|
||||
active={this.state.drawerActive}
|
||||
permanentAt="md"
|
||||
onOverlayClick={this.toggleDrawerActive}
|
||||
>
|
||||
<div style={{ fontSize: '1.2em' }}>
|
||||
{this.props.user ? `Hello, ${this.props.user.name}!` : 'Not logged in'}
|
||||
</div>
|
||||
<List selectable ripple>
|
||||
{this.props.user ? <ListItem caption="Home" onClick={() => this.context.router.history.push('/')} /> : null}
|
||||
{this.props.user ? <ListItem caption="Logout" /> : null}
|
||||
<ListItem caption="Help" />
|
||||
<ListItem caption="About" />
|
||||
<ListItem to="https://github.com/ambrosechua/chronos" caption="GitHub" />
|
||||
</List>
|
||||
</NavDrawer>
|
||||
<Panel>
|
||||
<AppBar title="Chronos" leftIcon="menu" onLeftIconClick={this.toggleDrawerActive}>
|
||||
<Navigation type="horizontal">
|
||||
<Link label="Inbox" icon="inbox" onClick={() => this.context.router.history.push('/login')} />
|
||||
<Link active label="Profile" icon="person" />
|
||||
</Navigation>
|
||||
</AppBar>
|
||||
{this.props.children}
|
||||
</Panel>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LayoutMain.defaultProps = {
|
||||
children: null,
|
||||
user: null,
|
||||
};
|
||||
|
||||
LayoutMain.propTypes = {
|
||||
children: React.PropTypes.node,
|
||||
user: React.PropTypes.shape({
|
||||
name: React.PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
LayoutMain.contextTypes = {
|
||||
router: React.PropTypes.shape({
|
||||
history: React.PropTypes.shape({
|
||||
push: React.PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import Container from './components/layouts/container';
|
||||
import Button from './components/button';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default class Navigation extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<nav className="navbar navbar-inverse bg-inverse sticky-top">
|
||||
<Container>
|
||||
<div className="d-flex flex-row">
|
||||
<span className="navbar-brand">Chronos</span>
|
||||
<ul className="navbar-nav mr-auto">
|
||||
<li className="nav-item">
|
||||
<Link className="nav-link" to="/login">Login</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="form-inline">
|
||||
<Button color="secondary" onClick={() => alert('test')}>Logout</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Card, CardTitle, CardText, CardActions, Button } from 'react-toolbox';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default class PageHome extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
cards: [
|
||||
{ key: 0, title: 'Math Test', description: 'Meow' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div style={{ padding: '1em' }}>
|
||||
{this.state.cards.map(card =>
|
||||
<Card key={card.key} style={{ margin: '1em', width: 'auto' }}>
|
||||
<CardTitle title={card.title} />
|
||||
<CardText>{card.description}</CardText>
|
||||
<CardActions>
|
||||
<Button label="Edit" />
|
||||
</CardActions>
|
||||
</Card>,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,17 +1,62 @@
|
|||
import React from 'react';
|
||||
|
||||
import Container from '../components/layouts/container';
|
||||
import Button from '../components/button';
|
||||
import { Dropdown } from 'react-toolbox';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default class PageLogin extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
schools: [],
|
||||
school: null,
|
||||
};
|
||||
|
||||
this.changeSchool = this.changeSchool.bind(this);
|
||||
|
||||
this.fetchSchools();
|
||||
}
|
||||
|
||||
async fetchSchools() {
|
||||
return fetch('/api/v1/schools')
|
||||
.then(data => data.json())
|
||||
.then((data) => {
|
||||
this.setState({
|
||||
schools: data.map(s => ({ value: s.id, label: s.name })),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
changeSchool(school) {
|
||||
this.setState({
|
||||
school,
|
||||
});
|
||||
this.context.router.history.push(`/login/${school}`);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Container>
|
||||
<Button>
|
||||
Login with Office365
|
||||
</Button>
|
||||
</Container>
|
||||
<section style={{ padding: '1em', margin: '0 auto', maxWidth: '480px' }}>
|
||||
<h1>
|
||||
Login
|
||||
</h1>
|
||||
<Dropdown
|
||||
onChange={this.changeSchool}
|
||||
source={this.state.schools}
|
||||
value={this.state.school}
|
||||
label="School"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PageLogin.contextTypes = {
|
||||
router: React.PropTypes.shape({
|
||||
history: React.PropTypes.shape({
|
||||
push: React.PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Input, Button, Snackbar } from 'react-toolbox';
|
||||
|
||||
import { UserManager } from 'oidc-client';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default class PageLoginSchool extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
id: parseInt(props.match.params.id, 10),
|
||||
school: {},
|
||||
openId: false,
|
||||
email: true,
|
||||
snackbarActive: false,
|
||||
};
|
||||
|
||||
this.loginOpenId = this.loginOpenId.bind(this);
|
||||
this.loginEmail = this.loginEmail.bind(this);
|
||||
|
||||
this.fetchSchool().then(() => {
|
||||
if (this.state.openId) {
|
||||
this.userManager = new UserManager({
|
||||
authority: `/api/v1/schools/${this.state.school.id}/oid`, // temp bypass: this.state.school.auth[0].oid_meta,
|
||||
client_id: this.state.school.auth[0].oid_cid,
|
||||
redirect_uri: `${window.location.origin}/login/${this.state.school.id}`,
|
||||
});
|
||||
this.userManager.getUser()
|
||||
.then((user) => {
|
||||
console.log(user);
|
||||
if (user) {
|
||||
this.context.router.history.push('/');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fetchSchool() {
|
||||
return fetch(`/api/v1/schools/${this.state.id}`)
|
||||
.then(data => data.json())
|
||||
.then((data) => {
|
||||
this.setState({
|
||||
school: data,
|
||||
openId: !!data.auth[0],
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
loginOpenId() {
|
||||
this.userManager.signinRedirect();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
loginEmail() {
|
||||
console.error('Not implemented');
|
||||
}
|
||||
|
||||
handleSnackbarTimeout() {
|
||||
this.setState({ snackbarActive: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<section style={{ padding: '1em', margin: '0 auto', maxWidth: '480px' }}>
|
||||
{this.state.openId &&
|
||||
<Button
|
||||
onClick={this.loginOpenId}
|
||||
icon="fingerprint"
|
||||
label="Authenticate with Office365"
|
||||
raised primary style={{ width: '100%' }}
|
||||
/>
|
||||
}
|
||||
{this.state.openId && this.state.email &&
|
||||
<div style={{ textAlign: 'center', margin: '1rem 0 0 0' }}>
|
||||
OR
|
||||
</div>
|
||||
}
|
||||
{this.state.email &&
|
||||
<div>
|
||||
<Input
|
||||
type="email"
|
||||
label="Email"
|
||||
/>
|
||||
<Button
|
||||
onClick={this.loginEmail}
|
||||
label="Authenticate with email"
|
||||
raised primary style={{ width: '100%' }}
|
||||
/>
|
||||
<Snackbar
|
||||
onTimeout={this.state.handleSnackbarTimeout}
|
||||
active={this.state.snackbarActive}
|
||||
timeout={2000}
|
||||
label="Not implemented"
|
||||
type="accept"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PageLoginSchool.propTypes = {
|
||||
match: React.PropTypes.shape({
|
||||
params: React.PropTypes.shape({
|
||||
id: React.PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
PageLoginSchool.contextTypes = {
|
||||
router: React.PropTypes.shape({
|
||||
history: React.PropTypes.shape({
|
||||
push: React.PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
|
@ -1,23 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import Container from '../components/layouts/container';
|
||||
import Row from '../components/layouts/row';
|
||||
import Column from '../components/layouts/column';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default class PageMain extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<Container>
|
||||
<Row>
|
||||
<Column width="3" breakpoint="lg">
|
||||
<h1>Hello, world! </h1>
|
||||
</Column>
|
||||
<Column>
|
||||
<h1>Hello, world! </h1>
|
||||
</Column>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { get } from 'https';
|
||||
import Router from 'express';
|
||||
import { WebError, UnauthenticatedError } from './errors';
|
||||
import { WebError, UnauthenticatedError, NotFoundError } from './errors';
|
||||
|
||||
export default class API {
|
||||
constructor(database) {
|
||||
|
@ -23,13 +24,30 @@ export default class API {
|
|||
this.database.getSchools()
|
||||
.then((data) => {
|
||||
res.json(data);
|
||||
}).catch(next);
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
this.router.get('/schools/:school', (req, res, next) => {
|
||||
this.database.getSchool(req.params.school)
|
||||
this.database.getSchoolWithAuth(req.params.school)
|
||||
.then((data) => {
|
||||
res.json(data);
|
||||
}).catch(next);
|
||||
res.json(Object.assign(data, {
|
||||
auth: data.auth.map(a => Object.assign(a, { oid_csecret: undefined })),
|
||||
}));
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
this.router.get('/schools/:school/oid/.well-known/openid-configuration', (req, res, next) => {
|
||||
this.database.getSchoolWithAuth(req.params.school)
|
||||
.then((data) => {
|
||||
// assume auth[0] exists
|
||||
const url = data.auth[0].oid_meta;
|
||||
if (url) {
|
||||
get(url, (d) => {
|
||||
d.pipe(res);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
|
||||
// Users
|
||||
|
@ -42,11 +60,16 @@ export default class API {
|
|||
this.router.get('/schools/:school/users/:id', this.auth, (req, res, next) => {
|
||||
this.database.getUser(req.params.school, req.params.id)
|
||||
.then((data) => {
|
||||
res.json(data);
|
||||
res.json(Object.assign(data, {
|
||||
pwd: undefined,
|
||||
oid_id: undefined,
|
||||
}));
|
||||
}).catch(next);
|
||||
});
|
||||
|
||||
// this.router.all('/*', this.auth);
|
||||
this.router.use('/*', (req, res, next) => {
|
||||
next(new NotFoundError());
|
||||
});
|
||||
this.router.use(API.error);
|
||||
}
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ export default class Database {
|
|||
FROM school
|
||||
`);
|
||||
}
|
||||
async getSchool(id) {
|
||||
async getSchoolWithAuth(id) {
|
||||
return this.query(`
|
||||
SELECT *
|
||||
FROM auth RIGHT JOIN school
|
||||
|
@ -36,7 +36,6 @@ export default class Database {
|
|||
school: undefined,
|
||||
name: undefined,
|
||||
domain: undefined,
|
||||
oid_csecret: undefined,
|
||||
})),
|
||||
}))
|
||||
.catch(err => err.withNoun('School'));
|
||||
|
@ -52,12 +51,12 @@ export default class Database {
|
|||
}
|
||||
async getUser(school, id) {
|
||||
return this.query(`
|
||||
SELECT id, name, email, role
|
||||
SELECT *
|
||||
FROM user
|
||||
WHERE user.school = ?
|
||||
AND user.id = ?
|
||||
`, [school, id], {
|
||||
required: true,
|
||||
single: true,
|
||||
})
|
||||
.catch(err => err.withNoun('User'));
|
||||
}
|
||||
|
@ -68,10 +67,14 @@ export default class Database {
|
|||
this.connection.query(query, values, (err, results, fields) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (options.required === true && results.length < 1) {
|
||||
} else if ((options.required || options.single) && results.length < 1) {
|
||||
reject(new NotFoundError());
|
||||
}
|
||||
resolve(results, fields);
|
||||
if (options.single) {
|
||||
resolve(results[0], fields);
|
||||
} else {
|
||||
resolve(results, fields);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -90,9 +93,10 @@ export default class Database {
|
|||
// database is up-to-date
|
||||
return true;
|
||||
} else if (semver.satisfies(oldVersion, '~0')) { // lmao forces database migration
|
||||
return true;
|
||||
// database needs to be updated
|
||||
return this.query(`DROP DATABASE ${DATABASE}`)
|
||||
.then(() => this.checkAndMigrate());
|
||||
// return this.query(`DROP DATABASE ${DATABASE}`)
|
||||
// .then(() => this.checkAndMigrate());
|
||||
}
|
||||
// database does not exist
|
||||
|
||||
|
@ -205,12 +209,16 @@ export default class Database {
|
|||
VALUES ('version', '${getVersion()}')
|
||||
`);
|
||||
|
||||
// TODO: Build admin interface to add schools
|
||||
// TODO: Build admin interface to add schools and school owner
|
||||
// add first school
|
||||
const firstSchool = await this.query(`
|
||||
INSERT INTO school (name, domain)
|
||||
VALUES (?, ?)
|
||||
`, ['NUS High School', 'nushigh.edu.sg']);
|
||||
await this.query(`
|
||||
INSERT INTO user (school, name, email, pwd_hash, role)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, [firstSchool.insertId, 'Ambrose Chua', 'h1310031@nushigh.edu.sg', '', 'OWN']);
|
||||
|
||||
// eslint-disable-next-line global-require
|
||||
const fs = require('fs');
|
||||
|
|
|
@ -11,7 +11,14 @@ const database = new Database({
|
|||
});
|
||||
const api = new API(database);
|
||||
|
||||
app.use('/', express.static(path.join(__dirname, '..', 'app')));
|
||||
// API mount
|
||||
app.use('/api/v1', api.router);
|
||||
// API fallback
|
||||
app.use('/api', (req, res) => res.end('API'));
|
||||
|
||||
app.listen(8080);
|
||||
// Assets
|
||||
app.use('/', express.static(path.join(__dirname, '..', 'app')));
|
||||
// Pages
|
||||
app.get('/*', (req, res) => res.sendFile(path.join(__dirname, '..', 'app', 'index.html')));
|
||||
|
||||
app.listen(process.env.PORT || 8080);
|
||||
|
|
Loading…
Reference in New Issue