Salle de conférence expérimentale #31
|
@ -4035,6 +4035,11 @@
|
|||
"randomfill": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"crypto-js": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz",
|
||||
"integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg=="
|
||||
},
|
||||
"css": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
|
||||
|
@ -5419,6 +5424,11 @@
|
|||
"integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==",
|
||||
"dev": true
|
||||
},
|
||||
"get-browser-rtc": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.0.2.tgz",
|
||||
"integrity": "sha1-u81AyEUaftTvXDc7gWmkCd0dEdk="
|
||||
},
|
||||
"get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
|
@ -6055,8 +6065,7 @@
|
|||
"inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
|
@ -6376,6 +6385,11 @@
|
|||
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=",
|
||||
"dev": true
|
||||
},
|
||||
"isomorphic.js": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.1.5.tgz",
|
||||
"integrity": "sha512-MkX5lLQApx/8IAIU31PKvpAZosnu2Jqcj1rM8TzxyA4CR96tv3SgMKQNTCxL58G7696Q57zd7ubHV/hTg+5fNA=="
|
||||
},
|
||||
"isstream": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
|
||||
|
@ -6496,6 +6510,14 @@
|
|||
"leven": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"lib0": {
|
||||
"version": "0.2.34",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.34.tgz",
|
||||
"integrity": "sha512-cqsVIMPgFlDtgQcpkt7HOY6W3sbYPIe3qxMnbRSwHTgiQancgm+TRDPx28mC6GUZ6lG6Nr0bIWf4Nog6dWUNUg==",
|
||||
"requires": {
|
||||
"isomorphic.js": "^0.1.3"
|
||||
}
|
||||
},
|
||||
"load-json-file": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
|
||||
|
@ -8006,11 +8028,15 @@
|
|||
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==",
|
||||
"dev": true
|
||||
},
|
||||
"queue-microtask": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.1.4.tgz",
|
||||
"integrity": "sha512-eY/4Obve9cE5FK8YvC1cJsm5cr7XvAurul8UtBDJ2PR1p5NmAwHtvAt5ftcLtwYRCUKNhxCneZZlxmUDFoSeKA=="
|
||||
},
|
||||
"randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
|
@ -8968,6 +8994,30 @@
|
|||
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
|
||||
"dev": true
|
||||
},
|
||||
"simple-peer": {
|
||||
"version": "9.7.2",
|
||||
"resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.7.2.tgz",
|
||||
"integrity": "sha512-xeMyxa9B4V0eA6mf17fVr8nm2QhAYFu+ZZv8zkSFFTjJETGF227CshwobrIYZuspJglMD63egcevQXGOrTIsuA==",
|
||||
"requires": {
|
||||
"debug": "^4.0.1",
|
||||
"get-browser-rtc": "^1.0.0",
|
||||
"queue-microtask": "^1.1.0",
|
||||
"randombytes": "^2.0.3",
|
||||
"readable-stream": "^3.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"readable-stream": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
|
||||
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
|
||||
"requires": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"slash": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",
|
||||
|
@ -9397,7 +9447,6 @@
|
|||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
|
@ -10114,8 +10163,7 @@
|
|||
"util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
|
||||
"dev": true
|
||||
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
|
||||
},
|
||||
"util.promisify": {
|
||||
"version": "1.0.0",
|
||||
|
@ -10811,6 +10859,33 @@
|
|||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"dev": true
|
||||
},
|
||||
"y-protocols": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.1.tgz",
|
||||
"integrity": "sha512-QP3fCM7c2gGfUi2nqf8gspyO4VW23zv3kNqPNdD3wNxMbuNQenMyoDVZYEo12jzR4RQ3aaDfPK62Sf31SVOmfg==",
|
||||
"requires": {
|
||||
"lib0": "^0.2.28"
|
||||
}
|
||||
},
|
||||
"y-webrtc": {
|
||||
"version": "10.1.6",
|
||||
"resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.1.6.tgz",
|
||||
"integrity": "sha512-b3pTIv9LcPuMb4nbDT3/kkgmcuQoTrBmaPbBqPH1LJMzI8HwYnMK8p5r0fBQJBI0YRor+i8BT15Evv1nQBP0zg==",
|
||||
"requires": {
|
||||
"lib0": "^0.2.32",
|
||||
"simple-peer": "^9.7.2",
|
||||
"ws": "^7.2.0",
|
||||
"y-protocols": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz",
|
||||
"integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"y18n": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
|
||||
|
@ -10936,6 +11011,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"yjs": {
|
||||
"version": "13.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.4.1.tgz",
|
||||
"integrity": "sha512-kIh0sprCTzIm2qyr1VsovkvjKzD2GR4WcU/McJpLAEvImCJHA78Q3S6uSLnhZX0i7FQdrLPCRT8DtTPEH73jnw==",
|
||||
"requires": {
|
||||
"lib0": "^0.2.33"
|
||||
}
|
||||
},
|
||||
"zen-observable": {
|
||||
"version": "0.8.15",
|
||||
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",
|
||||
|
|
|
@ -56,6 +56,7 @@
|
|||
"bs58": "^4.0.1",
|
||||
"bulma": "^0.9.0",
|
||||
"bulma-timeline": "^3.0.4",
|
||||
"crypto-js": "^4.0.0",
|
||||
"graphql": "^15.3.0",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
|
@ -66,6 +67,8 @@
|
|||
"redux-saga": "^1.1.3",
|
||||
"styled-components": "^4.4.1",
|
||||
"subscriptions-transport-ws": "^0.9.17",
|
||||
"typescript": "^3.8.3"
|
||||
"typescript": "^3.8.3",
|
||||
"y-webrtc": "^10.1.6",
|
||||
"yjs": "^13.4.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { createClient } from '../util/apollo';
|
|||
import { ApolloProvider } from '@apollo/client';
|
||||
import { LogoutPage } from './LogoutPage';
|
||||
import { UnauthorizedPage } from './UnauthorizedPage/UnauthorizedPage';
|
||||
import { ConferencePage } from './ConferencePage/ConferencePage';
|
||||
|
||||
export interface AppProps {
|
||||
|
||||
|
@ -44,6 +45,7 @@ export const App: FunctionComponent<AppProps> = () => {
|
|||
<Route path="/" exact component={HomePage} />
|
||||
<Route path="/unauthorized" exact component={UnauthorizedPage} />
|
||||
<PrivateRoute path="/profile" exact component={ProfilePage} />
|
||||
<PrivateRoute path="/conference" exact component={ConferencePage} />
|
||||
<PrivateRoute path="/workgroups/:id" exact component={WorkgroupPage} />
|
||||
<PrivateRoute path="/decisions/:id" component={DecisionSupportFilePage} />
|
||||
<PrivateRoute path="/dashboard" exact component={DashboardPage} />
|
||||
|
|
|
@ -0,0 +1,130 @@
|
|||
import React, { FunctionComponent, useEffect } from 'react';
|
||||
import { useUserProfile } from '../../gql/queries/profile';
|
||||
import { useConference } from '../../hooks/useConference';
|
||||
import { Page } from '../Page';
|
||||
import { Gravatar } from './Gravatar';
|
||||
|
||||
export interface ConferencePageProps {
|
||||
|
||||
}
|
||||
|
||||
const StatusHandRaised = 'hand-raised';
|
||||
const StatusThumbsUp = 'thumbs-up';
|
||||
const StatusThumbsDown = 'thumbs-down';
|
||||
const StatusNoVote = 'no-vote';
|
||||
|
||||
export const ConferencePage:FunctionComponent<ConferencePageProps> = () => {
|
||||
const { user } = useUserProfile();
|
||||
const { id, data, peers, setNickname, setEmail, ping, setStatus } = useConference();
|
||||
|
||||
const currentStatus = data.statuses[id];
|
||||
|
||||
useEffect(() => {
|
||||
if (peers.length === 0) return;
|
||||
if (!id || (!user.name && !user.email)) return;
|
||||
setNickname(user.name || user.email.split('@')[0]);
|
||||
setEmail(user.email);
|
||||
}, [user.name, user.email, peers.length]);
|
||||
|
||||
useEffect(() => {
|
||||
ping();
|
||||
const intervalId = setInterval(() => ping(), 30000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
const onStatusChange = (status: string) => {
|
||||
setStatus(currentStatus === status ? '' : status);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page title="Conference">
|
||||
<div className="container is-fluid">
|
||||
<section className="mt-5">
|
||||
<h3 className="is-size-3">Mes actions</h3>
|
||||
<div className="buttons has-addons">
|
||||
<button
|
||||
className={`button is-medium ${currentStatus === StatusHandRaised ? 'is-info is-selected' : ''}`}
|
||||
onClick={onStatusChange.bind(null, StatusHandRaised)}>
|
||||
<span className="icon">
|
||||
<i className="fa fa-hand-paper"></i>
|
||||
</span>
|
||||
<span>Lever la main</span>
|
||||
</button>
|
||||
<button
|
||||
className={`button is-medium ${currentStatus === StatusThumbsUp ? 'is-success is-selected' : ''}`}
|
||||
onClick={onStatusChange.bind(null, StatusThumbsUp)}>
|
||||
<span className="icon">
|
||||
<i className="fa fa-thumbs-up"></i>
|
||||
</span>
|
||||
<span>Voter pour</span>
|
||||
</button>
|
||||
<button
|
||||
className={`button is-medium ${currentStatus === StatusNoVote ? 'is-warning is-selected' : ''}`}
|
||||
onClick={onStatusChange.bind(null, StatusNoVote)}>
|
||||
<span className="icon">
|
||||
<i className="fa fa-mitten"></i>
|
||||
</span>
|
||||
<span>Ne se prononce pas</span>
|
||||
</button>
|
||||
<button
|
||||
className={`button is-medium ${currentStatus === StatusThumbsDown ? 'is-danger is-selected' : ''}`}
|
||||
onClick={onStatusChange.bind(null, StatusThumbsDown)}>
|
||||
<span className="icon">
|
||||
<i className="fa fa-thumbs-down"></i>
|
||||
</span>
|
||||
<span>Voter contre</span>
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="is-size-3">Assemblée</h3>
|
||||
<div className="columns mt-1">
|
||||
<UserCard className="column is-narrow"
|
||||
nickname={data.nicknames[id]}
|
||||
status={currentStatus}
|
||||
email={user.email} />
|
||||
{
|
||||
peers.map(p => {
|
||||
const nickname = data.nicknames[p] || '???';
|
||||
const email = data.emails[p] || '';
|
||||
return (
|
||||
<UserCard key={`peer-${p}`} className="column is-narrow"
|
||||
nickname={nickname}
|
||||
status={data.statuses[p]}
|
||||
email={email} />
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export interface UserCardProps {
|
||||
nickname: string
|
||||
email: string
|
||||
className?: string
|
||||
status: string
|
||||
};
|
||||
|
||||
export const UserCard:FunctionComponent<UserCardProps> = ({ nickname, email, className, status }) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="box">
|
||||
<div className="has-text-centered">
|
||||
<div className="mb-1">
|
||||
{ !status ? <span className="icon"><i className="far fa-2x fa-meh-blank"></i></span> : null }
|
||||
{ status === StatusHandRaised ? <span className="icon has-text-info"><i className="fa fa-2x fa-hand-paper"></i></span> : null }
|
||||
{ status === StatusThumbsUp ? <span className="icon has-text-success"><i className="fa fa-2x fa-thumbs-up"></i></span> : null }
|
||||
{ status === StatusNoVote ? <span className="icon has-text-warning"><i className="fa fa-2x fa-mitten"></i></span> : null }
|
||||
{ status === StatusThumbsDown ? <span className="icon has-text-danger"><i className="fa fa-2x fa-thumbs-down"></i></span> : null }
|
||||
</div>
|
||||
<figure className="image is-128x128 is-inline-block">
|
||||
<Gravatar className="is-rounded" email={email} />
|
||||
</figure>
|
||||
<h4 className="is-size-4">{nickname}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
import React, { FunctionComponent, useEffect, useState } from 'react';
|
||||
import md5 from 'crypto-js/md5';
|
||||
|
||||
export interface GravatarProps {
|
||||
className?: string
|
||||
email: string
|
||||
}
|
||||
|
||||
const defaultAvatarUrl = 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&s=128';
|
||||
|
||||
export const Gravatar:FunctionComponent<GravatarProps> = ({ className, email }) => {
|
||||
const [ avatarUrl, setAvatarUrl ] = useState(defaultAvatarUrl);
|
||||
useEffect(() => {
|
||||
const hash = md5(email.trim().toLowerCase());
|
||||
setAvatarUrl(`https://www.gravatar.com/avatar/${hash}?d=mp&s=128`);
|
||||
}, [email]);
|
||||
return (
|
||||
<img className={className} src={avatarUrl} />
|
||||
);
|
||||
}
|
|
@ -31,6 +31,20 @@ export function Navbar() {
|
|||
</a>
|
||||
</div>
|
||||
<div className={`navbar-menu ${isActive ? 'is-active' : ''}`}>
|
||||
<div className="navbar-start">
|
||||
{
|
||||
loggedIn ?
|
||||
<React.Fragment>
|
||||
<Link to="/dashboard" className="navbar-item">
|
||||
<i className="fa fa-columns"></i> Tableau de bord
|
||||
</Link>
|
||||
<Link to="/conference" className="navbar-item">
|
||||
<i className="fa fa-users"></i> Conférence
|
||||
</Link>
|
||||
</React.Fragment> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<div className="navbar-end">
|
||||
<div className="navbar-item">
|
||||
<div className="buttons">
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
import * as Y from 'yjs'
|
||||
import { WebrtcProvider, } from 'y-webrtc'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export function useConference() {
|
||||
const docRef = useRef(new Y.Doc());
|
||||
|
||||
const [ state, setState ] = useState({
|
||||
data: {
|
||||
heartbeats: {},
|
||||
emails: {},
|
||||
nicknames: {},
|
||||
statuses: {},
|
||||
},
|
||||
peers: [],
|
||||
id: null,
|
||||
});
|
||||
|
||||
const setData = (key: string, value: any) => {
|
||||
setState(state => ({...state, data: { ...state.data, [key]: value }}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const doc = docRef.current;
|
||||
const roomName = `${window.location.protocol}//${window.location.host}/daddy/conference`;
|
||||
const provider = new WebrtcProvider(roomName, docRef.current);
|
||||
|
||||
const onPeers = (evt) => {
|
||||
let peers = [...state.peers];
|
||||
peers = peers.filter(p => evt.removed.indexOf(p) === -1);
|
||||
peers.push(...evt.added);
|
||||
setState(state => ({ ...state, id: provider.room.peerId, peers }));
|
||||
};
|
||||
|
||||
provider.on('peers', onPeers);
|
||||
|
||||
const heartbeats = doc.getMap('heartbeats');
|
||||
heartbeats.observe(evt => setData('heartbeats', evt.currentTarget.toJSON()));
|
||||
|
||||
const nicknames = doc.getMap('nicknames');
|
||||
nicknames.observe(evt => setData('nicknames', evt.currentTarget.toJSON()));
|
||||
|
||||
const emails = doc.getMap('emails');
|
||||
emails.observe(evt => setData('emails', evt.currentTarget.toJSON()));
|
||||
|
||||
const statuses = doc.getMap('statuses');
|
||||
statuses.observe(evt => setData('statuses', evt.currentTarget.toJSON()));
|
||||
|
||||
return () => {
|
||||
provider.off('peers', onPeers);
|
||||
provider.destroy();
|
||||
docRef.current.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data: state.data,
|
||||
peers: state.peers,
|
||||
id: state.id,
|
||||
|
||||
setStatus: (status: string) => {
|
||||
const doc = docRef.current;
|
||||
const statuses = doc.getMap('statuses');
|
||||
statuses.set(state.id, status);
|
||||
},
|
||||
|
||||
ping: () => {
|
||||
const doc = docRef.current;
|
||||
const heartbeats = doc.getMap('heartbeats');
|
||||
heartbeats.set(state.id, (new Date()).toJSON());
|
||||
},
|
||||
|
||||
setNickname: (nickname: string) => {
|
||||
const doc = docRef.current;
|
||||
const nicknames = doc.getMap('nicknames');
|
||||
nicknames.set(state.id, nickname);
|
||||
},
|
||||
|
||||
setEmail: (email: string) => {
|
||||
const doc = docRef.current;
|
||||
const emails = doc.getMap('emails');
|
||||
emails.set(state.id, email);
|
||||
},
|
||||
};
|
||||
}
|
|
@ -96,6 +96,7 @@ func Mount(r *chi.Mux, config *config.Config) error {
|
|||
"/dashboard",
|
||||
"/decisions/*",
|
||||
"/unauthorized",
|
||||
"/conference",
|
||||
}
|
||||
|
||||
for _, cr := range clientRoutes {
|
||||
|
|
Loading…
Reference in New Issue