Compare commits

..

No commits in common. "adc4f163b7651141f015f24d11d5c523c2dfa9b5" and "919382f4e7d13bd2ce63f75b64aa5edfd819d8fb" have entirely different histories.

49 changed files with 2847 additions and 2687 deletions

View File

@ -1,5 +1,4 @@
REACT_APP_API_URL="http://localhost:2001" REACT_APP_API_URL="http://localhost:2001"
REACT_APP_IMAGINARY_URL="http://localhost:9000" REACT_APP_IMAGINARY_URL="http://localhost:9000"
REACT_APP_SENTRY_DSN=""
REACT_APP_SOURCE_REPO_URL="https://git.wilw.dev/wilw/treadl" REACT_APP_SOURCE_REPO_URL="https://git.wilw.dev/wilw/treadl"
REACT_APP_SUPPORT_ROOT="https://git.wilw.dev/wilw/treadl/wiki/" REACT_APP_SUPPORT_ROOT="https://git.wilw.dev/wilw/treadl/wiki/"

View File

@ -18,9 +18,8 @@
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-helmet": "^6.0.0", "react-helmet": "^6.0.0",
"react-joyride": "^2.4.0", "react-joyride": "^2.4.0",
"react-markdown": "^8.0.3", "react-redux": "^7.2.0",
"react-redux": "^8.0.1", "react-router-dom": "^5.1.2",
"react-router-dom": "^6.3.0",
"react-scripts": "3.4.1", "react-scripts": "3.4.1",
"react-toastify": "^4.4.0", "react-toastify": "^4.4.0",
"redux": "^4.0.0", "redux": "^4.0.0",

View File

@ -1,79 +1,55 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Routes, Route, Link } from 'react-router-dom'; import { Switch, Route, Link, withRouter } from 'react-router-dom';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux'; import { connect } from 'react-redux';
import { ToastContainer, toast } from 'react-toastify'; import { ToastContainer, toast } from 'react-toastify';
import { Grid, Divider, Icon, Container } from 'semantic-ui-react'; import { Grid, Divider, Icon, Container } from 'semantic-ui-react';
import api from 'api'; import api from 'api';
import actions from 'actions'; import actions from 'actions';
import utils from 'utils/utils.js';
import NavBar from 'components/includes/NavBar'; import NavBar from 'components/includes/NavBar';
import logo from 'images/logo/main.png';
import logo from 'images/logo/main.png';
import MarketingHome from './marketing/Home.js'; import MarketingHome from './marketing/Home.js';
import MarketingPricing from './marketing/Pricing.js';
import PrivacyPolicy from './marketing/PrivacyPolicy'; import PrivacyPolicy from './marketing/PrivacyPolicy';
import TermsOfUse from './marketing/TermsOfUse'; import TermsOfUse from './marketing/TermsOfUse';
import Login from './Login.js'; import Login from './Login.js';
import ForgottenPassword from './ForgottenPassword'; import ForgottenPassword from './ForgottenPassword';
import ResetPassword from './ResetPassword'; import ResetPassword from './ResetPassword';
import Home from './main/Home.js'; import Home from './main/Home.js';
import Profile from './main/users/Profile.js'; import Profile from './main/users/Profile.js';
import ProfileEdit from './main/users/EditProfile';
import ProfileProjects from './main/users/ProfileProjects';
import NewProject from './main/projects/New.js'; import NewProject from './main/projects/New.js';
import Project from './main/projects/Project.js'; import Project from './main/projects/Project.js';
import ProjectObjects from './main/projects/ProjectObjects.js';
import ProjectSettings from './main/projects/Settings.js';
import ObjectDraft from './main/projects/objects/Draft.js';
import ObjectList from './main/projects/ObjectList.js';
import Settings from './main/settings/Settings.js'; import Settings from './main/settings/Settings.js';
import SettingsIdentity from './main/settings/Identity';
import SettingsNotification from './main/settings/Notification';
import SettingsAccount from './main/settings/Account';
import NewGroup from './main/groups/New.js'; import NewGroup from './main/groups/New.js';
import Group from './main/groups/Group.js'; import Group from './main/groups/Group.js';
import GroupFeed from './main/groups/Feed.js';
import GroupMembers from './main/groups/Members.js';
import GroupProjects from './main/groups/Projects.js';
import GroupSettings from './main/groups/Settings.js';
import Root from './main/root'; import Root from './main/root';
//import Docs from './docs';
function App() {
const dispatch = useDispatch();
const { isAuthenticated, isAuthenticating, isAuthenticatingType, user, driftReady, syncedToDrift } = useSelector(state => { function App({ user, groups, syncedToDrift, driftReady, onOpenRegister, onCloseAuthentication, isAuthenticating, isAuthenticatingType, isAuthenticated, onLoginSuccess, onLogout, onReceiveProjects, onReceiveInvitations, onReceiveGroups, onDriftReady, onDriftSynced, helpModalOpen, openHelpModal, searchTerm, updateSearchTerm, searchPopupOpen, openSearchPopup, searchResults, updateSearchResults, searching, updateSearching, history }) {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const { isAuthenticated, isAuthenticating, isAuthenticatingType } = state.auth;
const { driftReady, syncedToDrift } = state.users;
return { isAuthenticated, isAuthenticating, isAuthenticatingType, user, driftReady, syncedToDrift };
});
const loggedInUserId = user?._id; const loggedInUserId = user?._id;
useEffect(() => { useEffect(() => {
api.auth.autoLogin(token => dispatch(actions.auth.receiveLogin(token))); api.auth.autoLogin(onLoginSuccess);
}, [dispatch]); }, [onLoginSuccess]);
useEffect(() => { useEffect(() => {
if (!loggedInUserId) return; if (!loggedInUserId) return;
api.users.getMyProjects(p => dispatch(actions.projects.receiveProjects(p))); api.users.getMyProjects(onReceiveProjects);
api.groups.getMine(g => dispatch(actions.groups.receiveGroups(g))); api.groups.getMine(onReceiveGroups);
api.invitations.get(({ invitations, sentInvitations}) => { api.invitations.get(({ invitations, sentInvitations}) => {
dispatch(actions.invitations.receiveInvitations(invitations.concat(sentInvitations))); onReceiveInvitations(invitations.concat(sentInvitations));
}); });
}, [dispatch, loggedInUserId]); }, [loggedInUserId, onReceiveProjects, onReceiveGroups, onReceiveInvitations]);
useEffect(() => { useEffect(() => {
window.drift && window.drift.on('ready', () => { window.drift && window.drift.on('ready', () => {
dispatch(actions.users.initDrift()); onDriftReady();
}); });
}, [dispatch]); }, [onDriftReady]);
useEffect(() => { useEffect(() => {
if (user && driftReady && !syncedToDrift && window.drift) { if (user && driftReady && !syncedToDrift && window.drift) {
@ -82,52 +58,35 @@ function App() {
username: user.username, username: user.username,
createdAt: user.createdAt, createdAt: user.createdAt,
}); });
dispatch(actions.users.syncDrift(null)); onDriftSynced();
} }
}, [dispatch, user, driftReady, syncedToDrift]); }, [user, driftReady, syncedToDrift, onDriftSynced]);
return ( return (
<div style={{display: 'flex', flexDirection: 'column', minHeight: '100vh'}}> <div style={{display: 'flex', flexDirection: 'column', minHeight: '100vh'}}>
<Helmet defaultTitle={'Treadl'} titleTemplate={`%s | Treadl`} /> <Helmet defaultTitle={'Treadl'} titleTemplate={`%s | Treadl`} />
<NavBar /> <NavBar />
<div style={{ flex: '1 0 0' }}> <div style={{ flex: '1 0 0' }}>
<Routes> <Switch>
<Route end path="/" element={isAuthenticated <Route exact path="/" render={props => (isAuthenticated
? <Home /> ? <Home {...props} />
: <MarketingHome onRegisterClicked={() => dispatch(actions.auth.openRegister())} /> : <MarketingHome {...props} onRegisterClicked={onOpenRegister} />)
} /> } />
<Route path="/privacy" element={<PrivacyPolicy />} /> <Route path="/pricing" render={props => <MarketingPricing {...props} onRegisterClicked={onOpenRegister} />} />
<Route path="/terms-of-use" element={<TermsOfUse />} /> <Route path="/privacy" component={PrivacyPolicy} />
<Route path="/password/forgotten" element={<ForgottenPassword />} /> <Route path="/terms-of-use" component={TermsOfUse} />
<Route path="/password/reset" element={<ResetPassword />} /> <Route path="/password/forgotten" component={ForgottenPassword} />
<Route path="/settings" element={<Settings />}> <Route path="/password/reset" component={ResetPassword} />
<Route path='identity' element={<SettingsIdentity />} /> <Route path="/settings" component={Settings} />
<Route path='notifications' element={<SettingsNotification />} /> <Route path="/projects/new" component={NewProject} />
<Route path='account' element={<SettingsAccount />} /> <Route path="/groups/new" component={NewGroup} />
<Route path='' element={<SettingsIdentity />} /> <Route path="/groups/:id" component={Group} />
</Route> <Route path='/root' component={Root} />
<Route path="/projects/new" element={<NewProject />} /> <Route path="/:username/edit" component={Profile} />
<Route path="/groups/new" element={<NewGroup />} /> <Route path="/:username/:projectPath" component={Project} />
<Route path="/groups/:id" element={<Group />}> <Route path="/:username" component={Profile} />
<Route path='feed' element={<GroupFeed />} /> </Switch>
<Route path='members' element={<GroupMembers />} /> <Login open={isAuthenticating} authType={isAuthenticatingType} onClose={onCloseAuthentication} />
<Route path='projects' element={<GroupProjects />} />
<Route path='settings' element={<GroupSettings />} />
<Route path='' end element={<GroupFeed />} />
</Route>
<Route path='/root' element={<Root />} />
<Route path='/:username/:projectPath' element={<Project />}>
<Route path="settings" element={<ProjectSettings />} />
<Route path=":objectId/edit" element={<ObjectDraft />} />
<Route path=":objectId" element={<ProjectObjects />} />
<Route path='' element={<ObjectList />} />
</Route>
<Route path="/:username" element={<Profile />}>
<Route path="edit" element={<ProfileEdit />} />
<Route path='' element={<ProfileProjects />} />
</Route>
</Routes>
<Login open={isAuthenticating} authType={isAuthenticatingType} onClose={() => dispatch(actions.auth.closeAuthentication())} />
<ToastContainer position={toast.POSITION.BOTTOM_CENTER} hideProgressBar/> <ToastContainer position={toast.POSITION.BOTTOM_CENTER} hideProgressBar/>
<Divider hidden section /> <Divider hidden section />
</div> </div>
@ -172,4 +131,34 @@ function App() {
); );
} }
export default App; const mapStateToProps = (state) => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
const { isAuthenticated, isAuthenticating, isAuthenticatingType } = state.auth;
const { driftReady, syncedToDrift } = state.users;
const { helpModalOpen, searchPopupOpen, searchTerm, searchResults, searching } = state.app;
return { isAuthenticated, isAuthenticating, isAuthenticatingType, user, groups, driftReady, syncedToDrift, helpModalOpen, searchPopupOpen, searchTerm, searchResults, searching };
};
const mapDispatchToProps = dispatch => ({
onOpenRegister: () => dispatch(actions.auth.openRegister()),
onCloseAuthentication: () => dispatch(actions.auth.closeAuthentication()),
onLoginSuccess: token => dispatch(actions.auth.receiveLogin(token)),
onLogout: () => dispatch(actions.auth.logout()),
onReceiveProjects: p => dispatch(actions.projects.receiveProjects(p)),
onReceiveGroups: g => dispatch(actions.groups.receiveGroups(g)),
onReceiveInvitations: i => dispatch(actions.invitations.receiveInvitations(i)),
onDriftSynced: (s) => dispatch(actions.users.syncDrift(s)),
onDriftReady: () => dispatch(actions.users.initDrift()),
openHelpModal: o => dispatch(actions.app.openHelpModal(o)),
openSearchPopup: o => dispatch(actions.app.openSearchPopup(o)),
updateSearchTerm: t => dispatch(actions.app.updateSearchTerm(t)),
updateSearchResults: r => dispatch(actions.app.updateSearchResults(r)),
updateSearching: s => dispatch(actions.app.updateSearching(s)),
});
const AppContainer = withRouter(connect(
mapStateToProps,
mapDispatchToProps,
)(App));
export default AppContainer;

View File

@ -1,44 +1,56 @@
import React, { useState } from 'react'; import React, { Component } from 'react';
import { import {
Card, Input, Divider, Button, Card, Input, Divider, Button,
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { connect } from 'react-redux';
import api from 'api'; import api from 'api';
function ForgottenPassword() { class ForgottenPassword extends Component {
const [email, setEmail] = useState(''); constructor(props) {
const [loading, setLoading] = useState(false); super(props);
const navigate = useNavigate(); this.state = { email: '', loading: false };
}
const sendEmail = () => { sendEmail = () => {
setLoading(true); this.setState({ loading: true });
api.auth.sendPasswordResetEmail(email, () => { api.auth.sendPasswordResetEmail(this.state.email, () => {
setLoading(false); this.setState({ loading: false });
toast.info('If your account exists, a password email has been sent'); toast.info('If your account exists, a password email has been sent');
navigate('/'); this.props.history.push('/');
}, (err) => { }, (err) => {
setLoading(false); this.setState({ loading: false });
toast.error(err.message); toast.error(err.message);
}); });
}; }
return ( render() {
<Card.Group centered style={{ marginTop: 50 }}> const { email, loading } = this.state;
<Card raised color="yellow"> return (
<Card.Content> <Card.Group centered style={{ marginTop: 50 }}>
<Card.Header>Forgotten your password?</Card.Header> <Card raised color="yellow">
<Card.Meta>Type your email address below, and we'll send you a password-reset email.</Card.Meta> <Card.Content>
<Divider hidden /> <Card.Header>Forgotten your password?</Card.Header>
<Input fluid type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="mary@example.com" autoFocus /> <Card.Meta>Type your email address below, and we'll send you a password-reset email.</Card.Meta>
</Card.Content> <Divider hidden />
<Card.Content extra textAlign="right"> <Input fluid type="email" value={email} onChange={e => this.setState({ email: e.target.value })} placeholder="mary@example.com" autoFocus />
<Button basic onClick={() => navigate('/')} content="Cancel" /> </Card.Content>
<Button color="teal" content="Send email" onClick={sendEmail} loading={loading} /> <Card.Content extra textAlign="right">
</Card.Content> <Button basic onClick={this.props.history.goBack} content="Cancel" />
</Card> <Button color="teal" content="Send email" onClick={this.sendEmail} loading={loading} />
</Card.Group> </Card.Content>
); </Card>
</Card.Group>
);
}
} }
export default ForgottenPassword; const mapStateToProps = state => ({ });
const mapDispatchToProps = dispatch => ({
});
const ForgottenPasswordContainer = connect(
mapStateToProps,
mapDispatchToProps,
)(ForgottenPassword);
export default ForgottenPasswordContainer;

View File

@ -1,175 +1,193 @@
import React, { useState } from 'react'; import React, { Component } from 'react';
import { import {
Message, Modal, Grid, Form, Input, Button, Message, Modal, Grid, Form, Input, Button,
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { useDispatch, useSelector } from 'react-redux'; import { connect } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link, withRouter } from 'react-router-dom';
import actions from 'actions'; import actions from 'actions';
import { api } from 'api'; import { api } from 'api';
import ReadingImage from 'images/reading.png'; import ReadingImage from 'images/reading.png';
function Login({ open, authType, onClose }) { class Login extends Component {
const [username, setUsername] = useState(''); constructor(props) {
const [email, setEmail] = useState(''); super(props);
const [password, setPassword] = useState(''); this.state = {
const [loading, setLoading] = useState(false); registering: false, username: '', email: '', password: '', loading: false,
const dispatch = useDispatch(); };
}
const { error } = useSelector(state => { login = () => {
const { loading, error } = state.auth; const { email, password } = this.state;
return { loading, error }; this.setState({ loading: true });
}); api.auth.login(email, password, this.props.onLoginStart, (data) => {
this.setState({ loading: false, password: '', email: '', username: '' });
const login = () => { this.props.onLoginSuccess(data);
setLoading(true); this.props.onClose();
api.auth.login(email, password, () => dispatch(actions.auth.requestLogin()), (data) => {
setLoading(false);
setPassword('');
setEmail('');
setUsername('');
dispatch(actions.auth.receiveLogin(data));
onClose();
}, (err) => { }, (err) => {
dispatch(actions.auth.loginError(err)); this.props.onLoginFailure(err);
setLoading(false); this.setState({ loading: false });
}); });
}; }
const register = () => { register = () => {
setLoading(true); const { username, email, password } = this.state;
api.auth.register(username, email, password, () => dispatch(actions.auth.requestLogin()), (data) => { this.setState({ loading: true });
setLoading(false); api.auth.register(username, email, password, this.props.onLoginStart, (data) => {
setPassword(''); this.setState({ loading: false, password: '', email: '', username: '' });
setEmail(''); this.props.onLoginSuccess(data);
setUsername(''); this.props.onClose();
dispatch(actions.auth.receiveLogin(data));
onClose();
}, (err) => { }, (err) => {
dispatch(actions.auth.loginError(err)); this.props.onLoginFailure(err);
setLoading(false); this.setState({ loading: false });
}); });
}; }
return ( handleChange = (event) => {
<div> const update = {};
{authType === 'register' update[event.target.name] = event.target.value;
&& ( this.setState(update);
<Modal dimmer="inverted" open={open} onClose={onClose}> }
<Modal.Header>
<span role="img" aria-label="wave">👋</span> Welcome!
<Button floated="right" onClick={onClose} basic content="Close" />
</Modal.Header>
<Modal.Content>
<Grid stackable>
<Grid.Column computer={8}>
<h4>Create a free account</h4>
<p>Having your own account lets you create and manage your own projects and patterns.</p>
<p>
<span role="img" aria-label="diamond">💎</span> It's free
</p>
<p>
<span role="img" aria-label="running person">🏃</span> Quick &amp; easy
</p>
<p>
<span role="img" aria-label="hug">🤗</span> Friendly interface
</p>
<p>
<span role="img" aria-label="pencil"> </span> In-built pattern-editor
</p>
<p>
<span role="img" aria-label="laptop">💻</span> Compatible with other software (using the WIF format)
</p>
<p>
<span role="img" aria-label="folder">📁</span> Add weaving patterns, images, and other files to your projects
</p>
<p>
<span role="img" aria-label="family">👨👩👧👦</span> Join a growing community of weavers and makers
</p>
<p>
<span role="img" aria-label="map">🗺</span> Contribute to the roadmap as we develop
</p>
<p>
<span role="img" aria-label="padlock">🔒</span> Secure and robust
</p>
</Grid.Column>
<Grid.Column computer={8}>
<Message>
<strong>
<span role="img" aria-label="wip">🚧</span> We're always building!
</strong> Sign-up to see what it's about, and we'd love to hear your feedback and work with you as we grow.
</Message>
{error && (<div className="ui warning message">{error.message}</div>)} render() {
<Form onSubmit={register}> const { loading } = this.state;
<Form.Field> return (
<label>Pick a username <div>
<small> (you can use letters, numbers and underscores)</small> {this.props.authType === 'register'
</label> && (
<Input autoFocus size="large" fluid name="username" type="text" value={username} onChange={e => setUsername(e.target.value)} /> <Modal dimmer="inverted" open={this.props.open} onClose={this.props.onClose}>
</Form.Field> <Modal.Header>
<Form.Field> <span role="img" aria-label="wave">👋</span> Welcome!
<label>Email address <Button floated="right" onClick={this.props.onClose} basic content="Close" />
<small> (for password resets &amp; other important things)</small> </Modal.Header>
</label> <Modal.Content>
<Input size="large" fluid name="email" type="email" value={email} onChange={e => setEmail(e.target.value)} /> <Grid stackable>
</Form.Field> <Grid.Column computer={8}>
<Form.Field> <h4>Create a free account</h4>
<label>Choose a strong password <p>Having your own account lets you create and manage your own projects and patterns.</p>
<small> (at least 6 characters)</small> <p>
</label> <span role="img" aria-label="diamond">💎</span> It's free
<Input size="large" fluid name="password" type="password" value={password} onChange={e => setPassword(e.target.value)} /> </p>
</Form.Field> <p>
<div className="ui hidden divider" /> <span role="img" aria-label="running person">🏃</span> Quick &amp; easy
<p style={{ fontSize: 11, color: 'rgb(180,180,180)' }}>By signing-up, you agree to the Treadl <a href="/privacy" target="_blank">Privacy Policy</a> and <a href="terms-of-use" target="_blank">Terms of Use</a>.</p> </p>
<Form.Button color="teal" type='submit' fluid size="large" loading={loading}> <p>
<span role="img" aria-label="rocket">🚀</span> Sign up! <span role="img" aria-label="hug">🤗</span> Friendly interface
</Form.Button> </p>
</Form> <p>
</Grid.Column> <span role="img" aria-label="pencil"> </span> In-built pattern-editor
</Grid> </p>
</Modal.Content> <p>
</Modal> <span role="img" aria-label="laptop">💻</span> Compatible with other software (using the WIF format)
) </p>
} <p>
{authType === 'login' <span role="img" aria-label="folder">📁</span> Add weaving patterns, images, and other files to your projects
&& ( </p>
<Modal dimmer="inverted" open={open} onClose={onClose}> <p>
<Modal.Header> <span role="img" aria-label="family">👨👩👧👦</span> Join a growing community of weavers and makers
<span role="img" aria-label="Peace"> </span> </p>
Welcome back <Button floated="right" onClick={onClose} basic content="Close" /> <p>
</Modal.Header> <span role="img" aria-label="map">🗺</span> Contribute to the roadmap as we develop
<Modal.Content> </p>
<Grid stackable> <p>
<Grid.Column computer={8}> <span role="img" aria-label="padlock">🔒</span> Secure and robust
<h4>Login to your account to manage your projects.</h4> </p>
<img alt='Reading' src={ReadingImage} style={{maxWidth:'95%', margin: '20px auto'}} /> </Grid.Column>
</Grid.Column> <Grid.Column computer={8}>
<Message>
<strong>
<span role="img" aria-label="wip">🚧</span> We're always building!
</strong> Sign-up to see what it's about, and we'd love to hear your feedback and work with you as we grow.
</Message>
<Grid.Column computer={8}> {this.props.error && (<div className="ui warning message">{this.props.error.message}</div>)}
{error && (<div className="ui warning message">{error.message}</div>)} <Form onSubmit={this.register}>
<Form onSubmit={login}> <Form.Field>
<Form.Field> <label>Pick a username
<label>Your email address or username</label> <small> (you can use letters, numbers and underscores)</small>
<Input autoFocus size="large" fluid name="email" type="text" value={email} onChange={e => setEmail(e.target.value)} placeholder='Email or username' /> </label>
</Form.Field> <Input autoFocus size="large" fluid name="username" type="text" value={this.state.username} onChange={event => this.handleChange(event)} />
<Form.Field> </Form.Field>
<label>Password <Form.Field>
<Link to="/password/forgotten" style={{ float: 'right' }} onClick={onClose}>Forgotten your password?</Link> <label>Email address
</label> <small> (for password resets &amp; other important things)</small>
<Input size="large" fluid name="password" type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder='Password' /> </label>
</Form.Field> <Input size="large" fluid name="email" type="email" value={this.state.email} onChange={event => this.handleChange(event)} />
<div className="ui hidden divider" /> </Form.Field>
<Form.Button type='submit' size="large" color="teal" fluid loading={loading}>Login</Form.Button> <Form.Field>
</Form> <label>Choose a strong password
</Grid.Column> <small> (at least 6 characters)</small>
</Grid> </label>
</Modal.Content> <Input size="large" fluid name="password" type="password" value={this.state.password} onChange={event => this.handleChange(event)} />
</Modal> </Form.Field>
) <div className="ui hidden divider" />
} <p style={{ fontSize: 11, color: 'rgb(180,180,180)' }}>By signing-up, you agree to the Treadl <a href="/privacy" target="_blank">Privacy Policy</a> and <a href="terms-of-use" target="_blank">Terms of Use</a>.</p>
</div> <Form.Button color="teal" type='submit' fluid size="large" loading={loading}>
); <span role="img" aria-label="rocket">🚀</span> Sign up!
</Form.Button>
</Form>
</Grid.Column>
</Grid>
</Modal.Content>
</Modal>
)
}
{this.props.authType === 'login'
&& (
<Modal dimmer="inverted" open={this.props.open} onClose={this.props.onClose}>
<Modal.Header>
<span role="img" aria-label="Peace"> </span>
Welcome back <Button floated="right" onClick={this.props.onClose} basic content="Close" />
</Modal.Header>
<Modal.Content>
<Grid stackable>
<Grid.Column computer={8}>
<h4>Login to your account to manage your projects.</h4>
<img alt='Reading' src={ReadingImage} style={{maxWidth:'95%', margin: '20px auto'}} />
</Grid.Column>
<Grid.Column computer={8}>
{this.props.error && (<div className="ui warning message">{this.props.error.message}</div>)}
<Form onSubmit={this.login}>
<Form.Field>
<label>Your email address or username</label>
<Input autoFocus size="large" fluid name="email" type="text" value={this.state.email} onChange={event => this.handleChange(event)} placeholder='Email or username' />
</Form.Field>
<Form.Field>
<label>Password
<Link to="/password/forgotten" style={{ float: 'right' }} onClick={this.props.onClose}>Forgotten your password?</Link>
</label>
<Input size="large" fluid name="password" type="password" value={this.state.password} onChange={event => this.handleChange(event)} placeholder='Password' />
</Form.Field>
<div className="ui hidden divider" />
<Form.Button type='submit' size="large" color="teal" fluid loading={loading}>Login</Form.Button>
</Form>
</Grid.Column>
</Grid>
</Modal.Content>
</Modal>
)
}
</div>
);
}
} }
export default Login; const mapStateToProps = (state) => {
const { auth } = state;
const { loading, isAuthenticated, error } = auth;
return { loading, isAuthenticated, error };
};
const mapDispatchToProps = dispatch => ({
onLoginStart: () => dispatch(actions.auth.requestLogin()),
onLoginSuccess: token => dispatch(actions.auth.receiveLogin(token)),
onLoginFailure: error => dispatch(actions.auth.loginError(error)),
});
const LoginContainer = withRouter(connect(
mapStateToProps,
mapDispatchToProps,
)(Login));
export default LoginContainer;

View File

@ -1,45 +1,57 @@
import React, { useState } from 'react'; import React, { Component } from 'react';
import { import {
Card, Input, Divider, Button, Card, Input, Divider, Button,
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { connect } from 'react-redux';
import api from 'api'; import api from 'api';
function ResetPassword() { class ResetPassword extends Component {
const [loading, setLoading] = useState(false); constructor(props) {
const [password, setPassword] = useState(''); super(props);
const navigate = useNavigate(); this.state = { password: '', loading: false };
}
const resetPassword = () => { resetPassword = () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const token = params.get('token'); const token = params.get('token');
setLoading(true); this.setState({ loading: true });
api.auth.updatePasswordWithToken(token, password, () => { api.auth.updatePasswordWithToken(token, this.state.password, () => {
setLoading(false); this.setState({ loading: false });
toast.info('Password changed successfully.'); toast.info('Password changed successfully.');
navigate('/'); this.props.history.push('/');
}, (err) => { }, (err) => {
setLoading(false); this.setState({ loading: false });
toast.error(err.message); toast.error(err.message);
}); });
}; }
return ( render() {
<Card.Group centered style={{ marginTop: 50 }}> const { password, loading } = this.state;
<Card raised color="yellow"> return (
<Card.Content> <Card.Group centered style={{ marginTop: 50 }}>
<Card.Header>Enter a new password</Card.Header> <Card raised color="yellow">
<Card.Meta>Enter a new password below.</Card.Meta> <Card.Content>
<Divider hidden /> <Card.Header>Enter a new password</Card.Header>
<Input fluid type="password" value={password} onChange={e => setPassword(e.target.value)} autoFocus /> <Card.Meta>Enter a new password below.</Card.Meta>
</Card.Content> <Divider hidden />
<Card.Content extra textAlign="right"> <Input fluid type="password" value={password} onChange={e => this.setState({ password: e.target.value })} autoFocus />
<Button color="teal" content="Change password" onClick={resetPassword} loading={loading} /> </Card.Content>
</Card.Content> <Card.Content extra textAlign="right">
</Card> <Button color="teal" content="Change password" onClick={this.resetPassword} loading={loading} />
</Card.Group> </Card.Content>
); </Card>
</Card.Group>
);
}
} }
export default ResetPassword; const mapStateToProps = state => ({ });
const mapDispatchToProps = dispatch => ({
});
const ResetPasswordContainer = connect(
mapStateToProps,
mapDispatchToProps,
)(ResetPassword);
export default ResetPasswordContainer;

View File

@ -1,29 +0,0 @@
import React, { useState } from 'react';
import { Container } from 'semantic-ui-react';
import { Switch, Route, Link } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import stuff from './stuff.md';
function Docs({ }) {
const [markdown, setMarkdown] = useState();
const getDoc = async key => {
const markdownFile = await fetch(stuff);//.then(res => res.text()).then(text => this.setState({ markdown: text }));
const markdown = await markdownFile.text();
console.log(markdown);
setMarkdown(markdown);
};
return (
<Container>
<h1>Treadl documentation</h1>
<ReactMarkdown># Hello, *world*!</ReactMarkdown>
<ReactMarkdown>{stuff}</ReactMarkdown>
<Switch>
<Route path="/docs/project" component={<ReactMarkdown>{markdown}</ReactMarkdown>} />
</Switch>
</Container>
);
};
export default Docs;

View File

@ -1,6 +0,0 @@
# Hello
This is some content
* and here
* there

View File

@ -1,72 +1,76 @@
import React, { useState, useRef } from 'react'; import React, { Component } from 'react';
import { Button } from 'semantic-ui-react'; import { Button } from 'semantic-ui-react';
import api from 'api'; import api from 'api';
class FileChooser extends Component {
constructor(props) {
super(props);
this.state = { isUploading: false };
}
function FileChooser({ onUploadStart, onUploadFinish, onError, onComplete, content, trigger, accept, forType, forObject }) { startUpload = () => {
const [isUploading, setIsUploading] = useState(false); this.setState({ isUploading: true });
const inputRef = useRef(); this.props.onUploadStart && this.props.onUploadStart();
}
const startUpload = () => { finishUpload = () => {
setIsUploading(true); this.setState({ isUploading: false });
onUploadStart && onUploadStart(); this.props.onUploadFinish && this.props.onUploadFinish();
}; }
const finishUpload = () => { chooseFile = () => this.refs.fileInput.click()
setIsUploading(false);
onUploadFinish && onUploadFinish();
};
const chooseFile = () => inputRef.current.click(); handleFileChosen = (e) => {
const handleFileChosen = (e) => {
const file = e.target.files && e.target.files[0]; const file = e.target.files && e.target.files[0];
if (file) { if (file) {
startUpload(); this.startUpload();
const fileName = file.name.replace(/[^a-zA-Z0-9_.]/g, '_'); const fileName = file.name.replace(/[^a-zA-Z0-9_.]/g, '_');
if (forType === 'project' && fileName.toLowerCase().indexOf('.wif') > -1) { if (this.props.forType === 'project' && fileName.toLowerCase().indexOf('.wif') > -1) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e2) => { reader.onload = (e2) => {
finishUpload(); this.finishUpload();
onComplete({ wif: e2.target.result, type: 'pattern' }); this.props.onComplete({ wif: e2.target.result, type: 'pattern' });
}; };
reader.readAsText(file); reader.readAsText(file);
} else { } else {
api.uploads.generateFileUploadRequest({ api.uploads.generateFileUploadRequest({
forType: forType, forId: forObject._id, name: fileName, size: file.size, type: file.type, forType: this.props.forType, forId: this.props.for._id, name: fileName, size: file.size, type: file.type,
}, async (response) => { }, async (response) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('PUT', response.signedRequest); xhr.open('PUT', response.signedRequest);
xhr.setRequestHeader('Content-Type', file.type); xhr.setRequestHeader('Content-Type', file.type);
xhr.onreadystatechange = () => { xhr.onreadystatechange = () => {
if (xhr.readyState === 4) { if (xhr.readyState === 4) {
finishUpload(); this.finishUpload();
if (xhr.status === 200) { if (xhr.status === 200) {
// We pass back the original file name so it can be displayed nicely // We pass back the original file name so it can be displayed nicely
onComplete({ storedName: response.fileName, name: file.name, type: 'file' }); this.props.onComplete({ storedName: response.fileName, name: file.name, type: 'file' });
} else if (onError) { } else if (this.props.onError) {
finishUpload(); this.finishUpload();
onError('Unable to upload file'); this.props.onError('Unable to upload file');
} }
} }
}; };
xhr.send(file); xhr.send(file);
}, (err) => { }, (err) => {
finishUpload(); this.finishUpload();
if (onError) onError(err.message || 'Unable to upload file'); if (this.props.onError) this.props.onError(err.message || 'Unable to upload file');
}); });
} }
} }
} }
return ( render() {
<React.Fragment> const { content, trigger, accept } = this.props;
<input type="file" style={{ display: 'none' }} ref={inputRef} onChange={handleFileChosen} accept={accept || '*'} /> return (
{trigger <React.Fragment>
? React.cloneElement(trigger, { loading: isUploading, onClick: chooseFile }) <input type="file" style={{ display: 'none' }} ref="fileInput" onChange={this.handleFileChosen} accept={accept || '*'} />
: <Button size="small" color="blue" icon="file" fluid content={content || 'Choose a file'} loading={isUploading} onClick={chooseFile} /> {trigger
} ? React.cloneElement(trigger, { loading: this.state.isUploading, onClick: this.chooseFile })
</React.Fragment> : <Button size="small" color="blue" icon="file" fluid content={content || 'Choose a file'} loading={this.state.isUploading} onClick={this.chooseFile} />
); }
</React.Fragment>
);
}
} }
export default FileChooser; export default FileChooser;

View File

@ -1,6 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, withRouter } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux'; import { connect } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import { Loader, List, Popup, Modal, Grid, Icon, Button, Container, Dropdown } from 'semantic-ui-react'; import { Loader, List, Popup, Modal, Grid, Icon, Button, Container, Dropdown } from 'semantic-ui-react';
import api from 'api'; import api from 'api';
@ -74,31 +74,22 @@ const SearchBar = styled.div`
} }
`; `;
function NavBar() { function NavBar({ user, groups, onOpenLogin, onOpenRegister, isAuthenticated, onLogout, onDriftSynced, helpModalOpen, openHelpModal, searchTerm, updateSearchTerm, searchPopupOpen, openSearchPopup, searchResults, updateSearchResults, searching, updateSearching, history }) {
const dispatch = useDispatch();
const { isAuthenticated, user, groups, helpModalOpen, searchPopupOpen, searchTerm, searchResults, searching } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
const { isAuthenticated } = state.auth;
const { helpModalOpen, searchPopupOpen, searchTerm, searchResults, searching } = state.app;
return { isAuthenticated, user, groups, helpModalOpen, searchPopupOpen, searchTerm, searchResults, searching };
});
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
dispatch(actions.app.openSearchPopup(false)); openSearchPopup(false);
}, [dispatch]); }, [history.location.pathname, openSearchPopup]);
const logout = () => api.auth.logout(() => { const logout = () => api.auth.logout(() => {
dispatch(actions.auth.logout()); onLogout();
dispatch(actions.users.syncDrift(false)) onDriftSynced(false);
if (window.drift) window.drift.reset(); if (window.drift) window.drift.reset();
navigate('/'); history.push('/');
}); });
const search = () => { const search = () => {
dispatch(actions.app.updateSearching(true)); updateSearching(true);
api.search.all(searchTerm, r => dispatch(actions.app.updateSearchResults(r))); api.search.all(searchTerm, updateSearchResults);
}; };
return ( return (
@ -108,9 +99,8 @@ function NavBar() {
{isAuthenticated {isAuthenticated
? ( ? (
<div className='nav-links'> <div className='nav-links'>
<Popup basic on='focus' open={searchPopupOpen} <Popup basic on='focus' open={searchPopupOpen} onOpen={e => openSearchPopup(true)} onClose={e => openSearchPopup(false)}
onOpen={e => dispatch(actions.app.openSearchPopup(true))} onClose={e => dispatch(actions.app.openSearchPopup(false))} trigger={<SearchBar><input placeholder='Click to search...' value={searchTerm} onChange={e => updateSearchTerm(e.target.value)} onKeyDown={e => e.keyCode === 13 && search()} /></SearchBar>}
trigger={<SearchBar><input placeholder='Click to search...' value={searchTerm} onChange={e => dispatch(actions.app.updateSearchTerm(e.target.value))} onKeyDown={e => e.keyCode === 13 && search()} /></SearchBar>}
content={<div style={{width: 300}} className='joyride-search'> content={<div style={{width: 300}} className='joyride-search'>
{!searchResults?.users && !searchResults?.groups ? {!searchResults?.users && !searchResults?.groups ?
<small> <small>
@ -181,7 +171,7 @@ function NavBar() {
} }
<span className='above-mobile'> <span className='above-mobile'>
<Button size='small' icon='help' basic inverted onClick={e => dispatch(actions.app.openHelpModal(true))}/> <Button size='small' icon='help' basic inverted onClick={e => openHelpModal(true)}/>
</span> </span>
<Dropdown direction="left" pointing="top right" icon={null} style={{marginLeft: 10}} <Dropdown direction="left" pointing="top right" icon={null} style={{marginLeft: 10}}
@ -223,20 +213,20 @@ function NavBar() {
trigger=<Button basic inverted icon="bars" /> trigger=<Button basic inverted icon="bars" />
> >
<Dropdown.Menu direction="left"> <Dropdown.Menu direction="left">
<Dropdown.Item onClick={() => dispatch(actions.auth.openLogin())}>Login</Dropdown.Item> <Dropdown.Item onClick={onOpenLogin}>Login</Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
</span> </span>
<span className="above-mobile"> <span className="above-mobile">
<Button inverted basic onClick={() => dispatch(actions.auth.openLogin())}>Login</Button> <Button inverted basic onClick={onOpenLogin}>Login</Button>
</span> </span>
<Button color="teal" onClick={() => dispatch(actions.auth.openRegister())}> <Button color="teal" onClick={onOpenRegister}>
<span role="img" aria-label="wave">👋</span> Sign-up <span role="img" aria-label="wave">👋</span> Sign-up
</Button> </Button>
</div> </div>
) )
} }
<Modal open={helpModalOpen} onClose={e => dispatch(actions.app.openHelpModal(false))}> <Modal open={helpModalOpen} onClose={e => openHelpModal(false)}>
<Modal.Header>Welcome to Treadl!</Modal.Header> <Modal.Header>Welcome to Treadl!</Modal.Header>
<Modal.Content> <Modal.Content>
<h3>Introduction</h3> <h3>Introduction</h3>
@ -257,7 +247,7 @@ function NavBar() {
<p>If you have any comments or feedback please tell us by emailing <a href="mailto:hello@treadl.com">hello@treadl.com</a>!</p> <p>If you have any comments or feedback please tell us by emailing <a href="mailto:hello@treadl.com">hello@treadl.com</a>!</p>
</Modal.Content> </Modal.Content>
<Modal.Actions> <Modal.Actions>
<Button onClick={e => dispatch(actions.app.openHelpModal(false))} color='teal' icon='check' content='OK' /> <Button onClick={e => openHelpModal(false)} color='teal' icon='check' content='OK' />
</Modal.Actions> </Modal.Actions>
</Modal> </Modal>
</Container> </Container>
@ -265,4 +255,29 @@ function NavBar() {
); );
} }
export default NavBar; const mapStateToProps = (state) => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
const { isAuthenticated } = state.auth;
const { helpModalOpen, searchPopupOpen, searchTerm, searchResults, searching } = state.app;
return { isAuthenticated, user, groups, helpModalOpen, searchPopupOpen, searchTerm, searchResults, searching };
};
const mapDispatchToProps = dispatch => ({
onOpenLogin: () => dispatch(actions.auth.openLogin()),
onOpenRegister: () => dispatch(actions.auth.openRegister()),
onLogout: () => dispatch(actions.auth.logout()),
onReceiveUser: user => dispatch(actions.users.receive(user)),
onDriftSynced: (s) => dispatch(actions.users.syncDrift(s)),
openHelpModal: o => dispatch(actions.app.openHelpModal(o)),
openSearchPopup: o => dispatch(actions.app.openSearchPopup(o)),
updateSearchTerm: t => dispatch(actions.app.updateSearchTerm(t)),
updateSearchResults: r => dispatch(actions.app.updateSearchResults(r)),
updateSearching: s => dispatch(actions.app.updateSearching(s)),
});
const NavBarContainer = withRouter(connect(
mapStateToProps,
mapDispatchToProps,
)(NavBar));
export default NavBarContainer;

View File

@ -84,7 +84,7 @@ const NewFeedMessage = connect(
> >
<Dropdown.Menu> <Dropdown.Menu>
<FileChooser <FileChooser
forType={forType} forObject={forObj} forType={forType} for={forObj}
trigger=<Dropdown.Item icon="upload" content="Upload a file from your computer" /> trigger=<Dropdown.Item icon="upload" content="Upload a file from your computer" />
onUploadStart={e => updateAttachmentUploading(true) } onUploadStart={e => updateAttachmentUploading(true) }
onUploadFinish={e => updateAttachmentUploading(false) } onUploadFinish={e => updateAttachmentUploading(false) }

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { Component } from 'react';
import { useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import { useNavigate } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { Button, Dropdown } from 'semantic-ui-react'; import { Button, Dropdown } from 'semantic-ui-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import actions from 'actions'; import actions from 'actions';
@ -8,61 +8,73 @@ import api from 'api';
import FileChooser from 'components/includes/FileChooser'; import FileChooser from 'components/includes/FileChooser';
function ObjectCreator({ project, onCreateObject, onError, fluid }) { class ObjectCreator extends Component {
const [isUploading, setIsUploading] = useState(false); constructor(props) {
const navigate = useNavigate(); super(props);
const dispatch = useDispatch(); this.state = { isUploading: false };
}
const createNewPattern = () => { createNewPattern = () => {
api.projects.createObject(project.fullName, { name: 'Untitled pattern', type: 'pattern' }, (object) => { api.projects.createObject(this.props.project.fullName, { name: 'Untitled pattern', type: 'pattern' }, (object) => {
dispatch(actions.objects.create(object)); this.props.onCreateObject(object);
navigate(`/${project.fullName}/${object._id}/edit`); this.props.history.push(`/${this.props.project.fullName}/${object._id}/edit`);
}); }, err => this.setState({ loading: false }));
}; }
const fileUploaded = (file) => { fileUploaded = (file) => {
setIsUploading(true); this.setState({ isUploading: true });
api.projects.createObject(project.fullName, { api.projects.createObject(this.props.project.fullName, {
name: file.name, storedName: file.storedName, type: file.type, wif: file.wif, name: file.name, storedName: file.storedName, type: file.type, wif: file.wif,
}, (object) => { }, (object) => {
setIsUploading(false); this.setState({ isUploading: false });
dispatch(actions.objects.create(object)); this.props.onCreateObject(object);
navigate(`/${project.fullName}/${object._id}`); this.props.history.push(`/${this.props.project.fullName}/${object._id}`);
}, (err) => { }, (err) => {
toast.error(err.message); toast.error(err.message);
setIsUploading(false); this.setState({ isUploading: false });
onError && onError(err); this.props.onError && this.props.onError(err);
}); });
}; }
return ( render() {
<Dropdown const { project, fluid } = this.props;
fluid={!!fluid} const { isUploading } = this.state;
icon={null} return (
trigger=<Button color="teal" fluid content="Add something" icon="plus" loading={isUploading} /> <Dropdown
> fluid={!!fluid}
<Dropdown.Menu> icon={null}
<Dropdown.Item onClick={createNewPattern} icon="pencil" content="Create a new weaving pattern" /> trigger=<Button color="teal" fluid content="Add something" icon="plus" loading={isUploading} />
<FileChooser >
forType="project" <Dropdown.Menu>
forObject={project} <Dropdown.Item onClick={this.createNewPattern} icon="pencil" content="Create a new weaving pattern" />
trigger=<Dropdown.Item icon="upload" content="Import a WIF file" /> <FileChooser
accept=".wif" forType="project"
onUploadStart={e => setIsUploading(true)} for={project}
onUploadFinish={e => setIsUploading(false)} trigger=<Dropdown.Item icon="upload" content="Import a WIF file" />
onComplete={fileUploaded} accept=".wif"
/> onUploadStart={e => this.setState({ isUploading: true })}
<FileChooser onUploadFinish={e => this.setState({ isUploading: false })}
forType="project" onComplete={this.fileUploaded}
forObject={project} />
trigger=<Dropdown.Item icon="cloud upload" content="Upload an image or a file" /> <FileChooser
onUploadStart={e => setIsUploading(true)} forType="project"
onUploadFinish={e => setIsUploading(false)} for={project}
onComplete={fileUploaded} trigger=<Dropdown.Item icon="cloud upload" content="Upload an image or a file" />
/> onUploadStart={e => this.setState({ isUploading: true })}
</Dropdown.Menu> onUploadFinish={e => this.setState({ isUploading: false })}
</Dropdown> onComplete={this.fileUploaded}
); />
</Dropdown.Menu>
</Dropdown>
);
}
} }
const mapDispatchToProps = dispatch => ({
onCreateObject: name => dispatch(actions.objects.create(name)),
onSelectObject: id => dispatch(actions.objects.select(id)),
});
const ObjectCreatorContainer = withRouter(connect(
null, mapDispatchToProps,
)(ObjectCreator));
export default ObjectCreator; export default ObjectCreatorContainer;

View File

@ -1,20 +1,16 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { Component } from 'react';
import pell from 'pell'; import pell from 'pell';
function RichText({ value, onChange }) { class RichText extends Component {
const [completedInit, setCompletedInit] = useState(false); ensureHTTP(url) {
const textboxRef = useRef();
const ensureHTTP = (url) => {
if (url.trim().toLowerCase().indexOf('http') !== 0) return `http://${url}`; if (url.trim().toLowerCase().indexOf('http') !== 0) return `http://${url}`;
return url; return url;
}; }
useEffect(() => { componentDidMount() {
if (completedInit) return;
pell.init({ pell.init({
element: textboxRef.current, element: this.refs.textbox,
onChange: onChange, onChange: this.props.onChange,
actions: [ actions: [
{ {
icon: '<i class="italic icon"></i>', icon: '<i class="italic icon"></i>',
@ -58,7 +54,7 @@ function RichText({ value, onChange }) {
title: 'Insert an image using a direct URL link', title: 'Insert an image using a direct URL link',
result: () => { result: () => {
const url = window.prompt('Enter the image URL'); // eslint-disable-line no-alert const url = window.prompt('Enter the image URL'); // eslint-disable-line no-alert
if (url) pell.exec('insertImage', ensureHTTP(url)); if (url) pell.exec('insertImage', this.ensureHTTP(url));
}, },
}, },
{ {
@ -67,16 +63,17 @@ function RichText({ value, onChange }) {
title: 'Add hyperlink to selected text', title: 'Add hyperlink to selected text',
result: () => { result: () => {
const url = window.prompt('Enter the link URL'); // eslint-disable-line no-alert const url = window.prompt('Enter the link URL'); // eslint-disable-line no-alert
if (url) pell.exec('createLink', ensureHTTP(url)); if (url) pell.exec('createLink', this.ensureHTTP(url));
}, },
}, },
], ],
defaultParagraphSeparator: 'p', defaultParagraphSeparator: 'p',
}).content.innerHTML = value || ''; }).content.innerHTML = this.props.value || '';
setCompletedInit(true); }
}, [completedInit, value, onChange]);
return <div ref={textboxRef} />; render() {
return <div ref="textbox" />;
}
} }
export default RichText; export default RichText;

View File

@ -1,24 +1,26 @@
import React from 'react'; import React, { Component } from 'react';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
function RichTextViewer({ content, style, className }) { class RichTextViewer extends Component {
if (!content) return null; render() {
return ( if (!this.props.content) return null;
<p return (
style={Object.assign({}, { breakLines: 'pre-line'}, style)} <p
className={className} style={Object.assign({}, { breakLines: 'pre-line'}, this.props.style)}
dangerouslySetInnerHTML={{ className={this.props.className}
__html: dangerouslySetInnerHTML={{
sanitizeHtml(content, { __html:
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'u']), sanitizeHtml(this.props.content, {
allowedAttributes: { allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'u']),
a: ['href', 'name', 'target'], allowedAttributes: {
img: ['src'], a: ['href', 'name', 'target'],
}, img: ['src'],
}), },
}} }),
/> }}
); />
);
}
} }
export default RichTextViewer; export default RichTextViewer;

View File

@ -1,55 +1,58 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { Component } from 'react';
import { Menu, Popup, Input } from 'semantic-ui-react'; import { Menu, Popup, Input } from 'semantic-ui-react';
import { connect } from 'react-redux'
import api from 'api'; import api from 'api';
function UserSearch({ onSelected, fluid }) { class UserSearch extends Component {
const [open, setOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [oldSearchTerm, setOldSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState();
const [searching, setSearching] = useState(false);
const updateSearchTerm = e => { constructor(props) {
setSearching(true); super(props);
setSearchTerm(e.target.value); this.state = { open: false, searchTerm: '', oldSearchTerm: '', searchResults: null, searching: false };
}; }
componentDidMount() {
this.searcher = setInterval(() => {
if (this.state.searchTerm !== this.state.oldSearchTerm) this.search();
}, 500);
}
componentWillUnmount() {
clearInterval(this.searcher);
}
const search = useCallback(() => { updateSearchTerm = e => {
if (searchTerm === oldSearchTerm) return; this.setState({ searching: true, searchTerm: e.target.value })
setOpen(false); }
setSearching(true); search = () => {
setOldSearchTerm(searchTerm); this.setState({ open: false, searching: true, oldSearchTerm: this.state.searchTerm });
if (!searchTerm) return setSearching(false); if (!this.state.searchTerm) return this.setState({ searching: false });
api.search.users(searchTerm, searchResults => { api.search.users(this.state.searchTerm, searchResults => this.setState({ open: true, searching: false, searchResults }), err => this.setState({searching: false}));
setOpen(true); }
setSearching(false);
setSearchResults(searchResults);
}, () => setSearching(false));
}, [oldSearchTerm, searchTerm]);
useEffect(() => {
const searcher = setInterval(() => search(), 500);
return () => clearInterval(searcher);
}, [search]);
const inputClicked = () => { inputClicked = () => {
if (searchResults) setOpen(true); if (this.state.searchResults) this.setState({ open: true });
}; }
const choose = user => { onSelected = user => {
onSelected && onSelected(user); this.props.onSelected && this.props.onSelected(user);
}; }
return ( render() {
<Popup hoverable position='bottom left' open={open} onClose={e => setOpen(false)} const { searchResults, searching, open } = this.state;
trigger={<Input fluid={fluid} icon='search' iconPosition='left' placeholder='Search for a username...' onChange={updateSearchTerm} loading={searching} onClick={inputClicked}/>} const { fluid } = this.props;
content={( return (
<Menu borderless vertical> <Popup hoverable position='bottom left' open={open} onClose={e => this.setState({ open: false })}
{searchResults && searchResults.map(r => trigger={<Input fluid={fluid} icon='search' iconPosition='left' placeholder='Search for a username...' onChange={this.updateSearchTerm} loading={searching} onClick={this.inputClicked}/>}
<Menu.Item key={r._id} as='a' icon='user' content={r.username} onClick={e => choose(r)} image={r.avatarUrl}/> content={(
)} <Menu borderless vertical>
</Menu> {searchResults && searchResults.map(r =>
)} <Menu.Item key={r._id} as='a' icon='user' content={r.username} onClick={e => this.onSelected(r)} image={r.avatarUrl}/>
/> )}
); </Menu>
)}
/>
);
}
} }
export default UserSearch;
const UserSearchContainer = connect(null, null)(UserSearch);
export default UserSearchContainer;

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Loader, Divider, Button, Message, Container, Segment, Grid, Card, Icon, List } from 'semantic-ui-react'; import { Loader, Divider, Button, Message, Container, Segment, Grid, Card, Icon, List } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
@ -13,37 +13,29 @@ import HelpLink from 'components/includes/HelpLink';
import ProjectCard from 'components/includes/ProjectCard'; import ProjectCard from 'components/includes/ProjectCard';
import Tour from 'components/includes/Tour'; import Tour from 'components/includes/Tour';
function Home() { function Home({ user, groups, projects, invitations, loadingProjects, onReceiveProjects, onReceiveInvitations, onDismissInvitation, onReceiveGroup, onJoinGroup }) {
const [runJoyride, setRunJoyride] = useState(false); const [runJoyride, setRunJoyride] = useState(false);
const dispatch = useDispatch();
const { user, projects, groups, invitations, loadingProjects } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
const invitations = state.invitations.invitations.filter(i => i.recipient === user?._id);
const projects = state.projects.projects.filter(p => p.user === user?._id);
return { user, projects, groups, invitations, loadingProjects: state.projects.loading };
});
useEffect(() => { useEffect(() => {
api.invitations.get(({ invitations, sentInvitations}) => { api.invitations.get(({ invitations, sentInvitations}) => {
dispatch(actions.invitations.receiveInvitations(invitations.concat(sentInvitations))); onReceiveInvitations(invitations.concat(sentInvitations));
}); });
}, [dispatch]); }, [onReceiveInvitations]);
useEffect(() => { useEffect(() => {
api.users.getMyProjects(p => dispatch(actions.projects.receiveProjects(p))); api.users.getMyProjects(onReceiveProjects);
setTimeout(() => setTimeout(() =>
setRunJoyride(true), 2000); setRunJoyride(true), 2000);
}, [dispatch]); }, [onReceiveProjects]);
const declineInvite = (invite) => { const declineInvite = (invite) => {
api.invitations.decline(invite._id, () => dispatch(actions.invitations.dismiss(invite._id)), err => toast.error(err.message)); api.invitations.decline(invite._id, () => onDismissInvitation(invite._id), err => toast.error(err.message));
} }
const acceptInvite = (invite) => { const acceptInvite = (invite) => {
api.invitations.accept(invite._id, (result) => { api.invitations.accept(invite._id, (result) => {
dispatch(actions.invitations.dismiss(invite._id)); onDismissInvitation(invite._id);
if (result.group) { if (result.group) {
dispatch(actions.groups.receiveGroup(result.group)); onReceiveGroup(result.group);
dispatch(actions.users.joinGroup(user._id, result.group._id)); onJoinGroup(user._id, result.group._id);
} }
}, err => toast.error(err.message)); }, err => toast.error(err.message));
} }
@ -178,4 +170,23 @@ function Home() {
); );
} }
export default Home; const mapStateToProps = state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
const invitations = state.invitations.invitations.filter(i => i.recipient === user?._id);
const projects = state.projects.projects.filter(p => p.user === user?._id);
return { user, projects, groups, invitations, loadingProjects: state.projects.loading };
}
const mapDispatchToProps = dispatch => ({
onReceiveGroup: group => dispatch(actions.groups.receiveGroup(group)),
onJoinGroup: (userId, groupId) => dispatch(actions.users.joinGroup(userId, groupId)),
onReceiveProjects: p => dispatch(actions.projects.receiveProjects(p)),
onDismissInvitation: id => dispatch(actions.invitations.dismiss(id)),
onReceiveInvitations: i => dispatch(actions.invitations.receiveInvitations(i)),
});
const HomeContainer = connect(
mapStateToProps,
mapDispatchToProps,
)(Home);
export default HomeContainer;

View File

@ -1,7 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Loader, Button, Segment } from 'semantic-ui-react'; import { Loader, Button, Segment } from 'semantic-ui-react';
import { useSelector, useDispatch } from 'react-redux'; import { withRouter } from 'react-router-dom';
import { useParams } from 'react-router-dom'; import { connect } from 'react-redux';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
@ -10,29 +10,16 @@ import FeedMessage from 'components/includes/FeedMessage';
import NewFeedMessage from 'components/includes/NewFeedMessage'; import NewFeedMessage from 'components/includes/NewFeedMessage';
import MessagesImage from 'images/messages.png'; import MessagesImage from 'images/messages.png';
function Feed() { function Feed({ user, group, entries, onReceiveEntry, onDeleteEntry, newEntry, onJoinGroup, replyingTo, updateReplyingTo, loadingEntries, updateLoadingEntries, match }) {
const dispatch = useDispatch();
const { id } = useParams();
const { user, group, entries, replyingTo, loadingEntries } = useSelector(state => {
const group = state.groups.groups.filter(g => g._id === id)[0];
const entries = state.groups.entries.filter(e => e.group === id).sort((a, b) => {
const aDate = new Date(a.createdAt);
const bDate = new Date(b.createdAt);
return aDate < bDate;
});
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const { replyingTo } = state.posts;
return { user, group, entries, replyingTo, loadingEntries: state.groups.loadingEntries };
});
const myGroups = user?.groups || []; const myGroups = user?.groups || [];
useEffect(() => { useEffect(() => {
dispatch(actions.groups.updateLoadingEntries(true)); updateLoadingEntries(true);
api.groups.getEntries(id, entries => { api.groups.getEntries(match.params.id, entries => {
dispatch(actions.groups.updateLoadingEntries(false)); updateLoadingEntries(false);
entries.forEach(e => dispatch(actions.groups.receiveEntry(e))); entries.forEach(e => onReceiveEntry(e));
}); });
}, [dispatch, id, myGroups.length]); }, [match.params.id, myGroups.length, onReceiveEntry, updateLoadingEntries]);
const mainEntries = entries && entries.filter(e => !e.inReplyTo); const mainEntries = entries && entries.filter(e => !e.inReplyTo);
@ -40,9 +27,9 @@ function Feed() {
<div> <div>
{utils.isInGroup(user, group._id) && <> {utils.isInGroup(user, group._id) && <>
{replyingTo ? {replyingTo ?
<Button style={{marginBottom: 20}} color='teal' content='Write a new post' onClick={() => dispatch(actions.posts.updateReplyingTo(null))} /> <Button style={{marginBottom: 20}} color='teal' content='Write a new post' onClick={() => updateReplyingTo(null)} />
: :
<NewFeedMessage user={user} group={group} forType='group' onPosted={e => dispatch(actions.groups.receiveEntry(e))}/> <NewFeedMessage user={user} group={group} forType='group' onPosted={onReceiveEntry}/>
} }
{loadingEntries && !mainEntries?.length && {loadingEntries && !mainEntries?.length &&
<div style={{textAlign:'center'}}> <div style={{textAlign:'center'}}>
@ -58,11 +45,35 @@ function Feed() {
</Segment> </Segment>
} }
{mainEntries?.map(e => {mainEntries?.map(e =>
<FeedMessage key={e._id} user={user} forType='group' group={group} post={e} replies={entries.filter(r => r.inReplyTo === e._id)} onDeleted={id => dispatch(actions.groups.deleteEntry(id))} onReplyPosted={e => dispatch(actions.groups.receiveEntry(e))} /> <FeedMessage key={e._id} user={user} forType='group' group={group} post={e} replies={entries.filter(r => r.inReplyTo === e._id)} onDeleted={onDeleteEntry} onReplyPosted={onReceiveEntry} />
)} )}
</>} </>}
</div> </div>
) )
} }
export default Feed; const mapStateToProps = (state, ownProps) => {
const { id } = ownProps.match.params;
const group = state.groups.groups.filter(g => g._id === id)[0];
const entries = state.groups.entries.filter(e => e.group === id).sort((a, b) => {
const aDate = new Date(a.createdAt);
const bDate = new Date(b.createdAt);
return aDate < bDate;
});
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const projects = state.projects.projects.filter(p => p.user === (user && user._id));
const { replyingTo } = state.posts;
return { user, group, loading: state.groups.loading, errorMessage: state.groups.errorMessage, projects, newEntry: state.groups.newEntry, entries, replyingTo, loadingEntries: state.groups.loadingEntries };
};
const mapDispatchToProps = dispatch => ({
onReceiveEntry: entry => dispatch(actions.groups.receiveEntry(entry)),
onDeleteEntry: id => dispatch(actions.groups.deleteEntry(id)),
onJoinGroup: (userId, groupId) => dispatch(actions.users.joinGroup(userId, groupId)),
updateReplyingTo: entryId => dispatch(actions.posts.updateReplyingTo(entryId)),
updateLoadingEntries: l => dispatch(actions.groups.updateLoadingEntries(l)),
});
const FeedContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(Feed));
export default FeedContainer;

View File

@ -1,8 +1,8 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Segment, Loader, Menu, Message, Container, Button, Icon, Grid, Card } from 'semantic-ui-react'; import { Segment, Loader, Menu, Message, Container, Button, Icon, Grid, Card } from 'semantic-ui-react';
import { Outlet, Link, useParams } from 'react-router-dom'; import { Switch, Route, Link, withRouter } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux'; import { connect } from 'react-redux';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
import actions from 'actions'; import actions from 'actions';
@ -10,62 +10,53 @@ import api from 'api';
import UserChip from 'components/includes/UserChip'; import UserChip from 'components/includes/UserChip';
import HelpLink from 'components/includes/HelpLink'; import HelpLink from 'components/includes/HelpLink';
import Feed from './Feed.js';
import Members from './Members.js';
import Projects from './Projects.js';
import Settings from './Settings.js';
function Group() { function Group({ user, group, requests, myRequests, loading, errorMessage, onReceiveGroup, onRequest, onRequestFailed, onJoinGroup, onLeaveGroup, onSubsUpdated, onReceiveInvitations, invitations, onDismissInvitation, match }) {
const { id } = useParams();
const dispatch = useDispatch();
const { user, group, loading, errorMessage, requests, myRequests, invitations } = useSelector(state => {
let group;
state.groups.groups.forEach((g) => {
if (g._id === id) group = g;
});
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const requests = state.invitations.invitations.filter(i => i.recipientGroup === group?._id);
const myRequests = state.invitations.invitations.filter(i => i.recipientGroup === group?._id && i.user === user?._id);
const invitations = state.invitations.invitations.filter(i => i.recipient === user?._id && i.typeId === group?._id);
return { user, group, loading: state.groups.loading, errorMessage: state.groups.errorMessage, requests, myRequests, invitations };
});
useEffect(() => { useEffect(() => {
dispatch(actions.groups.request()); onRequest();
api.groups.get(id, g => dispatch(actions.groups.receiveGroup(g)), err => dispatch(actions.groups.requestFailed(err))); api.groups.get(match.params.id, onReceiveGroup, onRequestFailed);
}, [dispatch, id]); }, [match.params.id, onRequest, onReceiveGroup, onRequestFailed]);
const join = () => { const join = () => {
if (!user) return toast.warning('Please login or sign-up first'); if (!user) return toast.warning('Please login or sign-up first');
api.groups.createMember(id, user._id, () => { api.groups.createMember(match.params.id, user._id, () => {
dispatch(actions.users.joingGroup(user._id, id)); onJoinGroup(user._id, match.params.id);
}, err => toast.error(err.message)); }, err => toast.error(err.message));
} }
const leave = () => { const leave = () => {
utils.confirm('Really leave this group?', 'You may not be able to re-join the group yourself.').then(() => { utils.confirm('Really leave this group?', 'You may not be able to re-join the group yourself.').then(() => {
api.groups.deleteMember(id, user._id, () => { api.groups.deleteMember(match.params.id, user._id, () => {
dispatch(actions.users.leaveGroup(user._id, id)); onLeaveGroup(user._id, match.params.id);
}, err => toast.error(err.message)); }, err => toast.error(err.message));
}, () => {}); }, () => {});
} }
const toggleEmailSub = (key, enable) => { const toggleEmailSub = (key, enable) => {
if (enable) if (enable)
api.users.createEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message)); api.users.createEmailSubscription(user.username, key, ({ subscriptions }) => onSubsUpdated(user._id, subscriptions), err => toast.error(err.message));
else else
api.users.deleteEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message)); api.users.deleteEmailSubscription(user.username, key, ({ subscriptions }) => onSubsUpdated(user._id, subscriptions), err => toast.error(err.message));
} }
const requestToJoin = () => { const requestToJoin = () => {
api.groups.createJoinRequest(group._id, invitation => { api.groups.createJoinRequest(group._id, invitation => {
toast.success('Request to join sent'); toast.success('Request to join sent');
dispatch(actions.invitations.receiveInvitations([invitation])); onReceiveInvitations([invitation]);
}, err => toast.error(err.message)); }, err => toast.error(err.message));
} }
const declineInvite = (invite) => { const declineInvite = (invite) => {
api.invitations.decline(invite._id, () => dispatch(actions.invitations.dismiss(invite._id)), err => toast.error(err.message)); api.invitations.decline(invite._id, () => onDismissInvitation(invite._id), err => toast.error(err.message));
} }
const acceptInvite = (invite) => { const acceptInvite = (invite) => {
api.invitations.accept(invite._id, (result) => { api.invitations.accept(invite._id, (result) => {
dispatch(actions.invitations.dismiss(invite._id)); onDismissInvitation(invite._id);
if (result.group) { if (result.group) {
dispatch(actions.users.joinGroup(user._id, result.group._id)); onJoinGroup(user._id, result.group._id);
} }
}, err => toast.error(err.message)); }, err => toast.error(err.message));
} }
@ -177,7 +168,13 @@ function Group() {
} }
</Segment> </Segment>
} }
<Outlet /> <Switch>
<Route path='/groups/:id' exact render={() => <Feed group={group} />} />
<Route path='/groups/:id/feed' render={() => <Feed group={group} />} />
<Route path='/groups/:id/members' render={() => <Members group={group} />} />
<Route path='/groups/:id/projects' render={() => <Projects group={group} />} />
<Route path='/groups/:id/settings' render={() => <Settings group={group} />} />
</Switch>
</> </>
: :
<Message>Please login to view or join this group.</Message> <Message>Please login to view or join this group.</Message>
@ -190,4 +187,31 @@ function Group() {
); );
} }
export default Group; const mapStateToProps = (state, ownProps) => {
const { id } = ownProps.match.params;
let group;
state.groups.groups.forEach((g) => {
if (g._id === id) group = g;
});
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const requests = state.invitations.invitations.filter(i => i.recipientGroup === group?._id);
const myRequests = state.invitations.invitations.filter(i => i.recipientGroup === group?._id && i.user === user?._id);
const invitations = state.invitations.invitations.filter(i => i.recipient === user?._id && i.typeId === group?._id);
return { user, group, loading: state.groups.loading, errorMessage: state.groups.errorMessage, requests, myRequests, invitations };
};
const mapDispatchToProps = dispatch => ({
onRequest: () => dispatch(actions.groups.request()),
onRequestFailed: err => dispatch(actions.groups.requestFailed(err)),
onReceiveGroup: group => dispatch(actions.groups.receiveGroup(group)),
onUpdateGroup: (id, update) => dispatch(actions.groups.updateGroup(id, update)),
onJoinGroup: (userId, groupId) => dispatch(actions.users.joinGroup(userId, groupId)),
onLeaveGroup: (userId, groupId) => dispatch(actions.users.leaveGroup(userId, groupId)),
onSubsUpdated: (id, subs) => dispatch(actions.users.updateSubscriptions(id, subs)),
onReceiveInvitations: i => dispatch(actions.invitations.receiveInvitations(i)),
onDismissInvitation: id => dispatch(actions.invitations.dismiss(id)),
});
const GroupContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(Group));
export default GroupContainer;

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Grid, Table, Button, Input, Label, Header, Loader, Segment, Dropdown, Card } from 'semantic-ui-react'; import { Grid, Table, Button, Input, Label, Header, Loader, Segment, Dropdown, Card } from 'semantic-ui-react';
import { useSelector, useDispatch } from 'react-redux'; import { withRouter } from 'react-router-dom';
import { useParams } from 'react-router-dom'; import { connect } from 'react-redux';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import moment from 'moment'; import moment from 'moment';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
@ -12,19 +12,9 @@ import UserChip from 'components/includes/UserChip';
import HelpLink from 'components/includes/HelpLink'; import HelpLink from 'components/includes/HelpLink';
import UserSearch from 'components/includes/UserSearch'; import UserSearch from 'components/includes/UserSearch';
function Members() { function Members({ user, group, members, requests, loading, onReceiveUser, onJoinGroup, onLeaveGroup, onUpdateGroupLoading, onDismissInvitation }) {
const [invitations, setInvitations] = useState([]); const [invitations, setInvitations] = useState([]);
const joinLinkRef = useRef(null); const joinLinkRef = useRef(null);
const { id } = useParams();
const dispatch = useDispatch();
const { user, group, members, loading, requests } = useSelector(state => {
const group = state.groups.groups.filter(g => g._id === id)[0];
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const members = state.users.users.filter(u => utils.isInGroup(u, id));
const requests = state.invitations.invitations.filter(i => i.recipientGroup === group?._id);
return { user, group, members, loading: state.groups.loading, requests };
});
useEffect(() => { useEffect(() => {
if (utils.isGroupAdmin(user, group)) { if (utils.isGroupAdmin(user, group)) {
@ -32,15 +22,15 @@ function Members() {
} }
}, [user, group]); }, [user, group]);
useEffect(() => { useEffect(() => {
dispatch(actions.users.request(true)); onUpdateGroupLoading(true);
api.groups.getMembers(group._id, members => { api.groups.getMembers(group._id, members => {
members.forEach(u => dispatch(actions.users.receive(u))); members.forEach(onReceiveUser);
dispatch(actions.users.request(false)); onUpdateGroupLoading(false);
}, err => { }, err => {
toast.error(err.message); toast.error(err.message);
dispatch(actions.users.request(false)); onUpdateGroupLoading(false);
}); });
}, [dispatch, group]); }, [group, onReceiveUser, onUpdateGroupLoading]);
const copyLink = () => { const copyLink = () => {
joinLinkRef.current.select(); joinLinkRef.current.select();
@ -50,7 +40,7 @@ function Members() {
const kickUser = (id) => { const kickUser = (id) => {
utils.confirm('Really kick this user?').then(() => { utils.confirm('Really kick this user?').then(() => {
api.groups.deleteMember(group._id, id, () => { api.groups.deleteMember(group._id, id, () => {
dispatch(actions.users.leaveGroup(id, group._id)); onLeaveGroup(id, group._id);
}, err => toast.error(err.message)); }, err => toast.error(err.message));
}, () => {}); }, () => {});
} }
@ -70,14 +60,14 @@ function Members() {
} }
const approveRequest = (invite) => { const approveRequest = (invite) => {
api.invitations.accept(invite._id, (result) => { api.invitations.accept(invite._id, (result) => {
dispatch(actions.invitations.dismiss(invite._id)) onDismissInvitation(invite._id);
dispatch(actions.users.receive(invite.invitedBy)); onReceiveUser(invite.invitedBy);
dispatch(actions.users.joinGroup(invite.user, group._id)); onJoinGroup(invite.user, group._id);
toast.success(`${invite.invitedBy.username} is now a member`); toast.success(`${invite.invitedBy.username} is now a member`);
}, err => toast.error(err.message)); }, err => toast.error(err.message));
} }
const declineRequest = (request) => { const declineRequest = (request) => {
api.invitations.decline(request._id, () => dispatch(actions.invitations.dismiss(request._id)), api.invitations.decline(request._id, () => onDismissInvitation(request._id),
err => toast.error(err.message)); err => toast.error(err.message));
} }
@ -174,4 +164,23 @@ function Members() {
) )
} }
export default Members; const mapStateToProps = (state, ownProps) => {
const { id } = ownProps.match.params;
const group = state.groups.groups.filter(g => g._id === id)[0];
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const members = state.users.users.filter(u => utils.isInGroup(u, id));
const requests = state.invitations.invitations.filter(i => i.recipientGroup === group?._id);
return { user, group, members, loading: state.groups.loading, errorMessage: state.groups.errorMessage, requests };
};
const mapDispatchToProps = dispatch => ({
onDismissInvitation: id => dispatch(actions.invitations.dismiss(id)),
onReceiveUser: user => dispatch(actions.users.receive(user)),
onJoinGroup: (userId, groupId) => dispatch(actions.users.joinGroup(userId, groupId)),
onLeaveGroup: (userId, groupId) => dispatch(actions.users.leaveGroup(userId, groupId)),
onUpdateGroupLoading: l => dispatch(actions.users.request(l)),
});
const MembersContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(Members));
export default MembersContainer;

View File

@ -2,30 +2,23 @@ import React from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Icon, Form, Grid, Input, Checkbox, Button, Divider } from 'semantic-ui-react'; import { Icon, Form, Grid, Input, Checkbox, Button, Divider } from 'semantic-ui-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useNavigate } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux'; import { connect } from 'react-redux';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
import HelpLink from 'components/includes/HelpLink'; import HelpLink from 'components/includes/HelpLink';
function NewGroup() { function NewGroup({ user, newGroupName, newGroupDescription, newGroupClosed, onUpdateGroupName, onUpdateGroupDescription, onUpdateGroupClosed, onReceiveGroup, loading, onUpdateGroupLoading, history }) {
const navigate = useNavigate();
const dispatch = useDispatch();
const { newGroupName, newGroupDescription, newGroupClosed, loading } = useSelector(state => {
const { loading, newGroupName, newGroupDescription, newGroupClosed } = state.groups;
return { newGroupName, newGroupDescription, newGroupClosed, loading };
});
const createGroup = () => { const createGroup = () => {
dispatch(actions.groups.request(true)); onUpdateGroupLoading(true);
api.groups.create({ name: newGroupName, description: newGroupDescription, closed: newGroupClosed }, (group) => { api.groups.create({ name: newGroupName, description: newGroupDescription, closed: newGroupClosed }, (group) => {
dispatch(actions.groups.receiveGroup(group)); onReceiveGroup(group);
dispatch(actions.groups.request(false)); onUpdateGroupLoading(false);
navigate(`/groups/${group._id}/members`); history.push(`/groups/${group._id}/members`);
}, (err) => { }, (err) => {
dispatch(actions.groups.request(false)); onUpdateGroupLoading(false);
toast.error(err.message); toast.error(err.message);
}); });
} }
@ -45,12 +38,12 @@ function NewGroup() {
<h3>About your group</h3> <h3>About your group</h3>
<p>Give your group a short name. You can change this whenever you like.</p> <p>Give your group a short name. You can change this whenever you like.</p>
<Input autoFocus type="text" fluid onChange={e => dispatch(actions.groups.updateNewGroupName(e.target.value))} value={newGroupName} /> <Input autoFocus type="text" fluid onChange={e => onUpdateGroupName(e.target.value)} value={newGroupName} />
<Divider hidden /> <Divider hidden />
<p><strong>Optional:</strong> Write a short description to describe your group to others.</p> <p><strong>Optional:</strong> Write a short description to describe your group to others.</p>
<Form><Form.TextArea placeholder="Group description (optional)..." value={newGroupDescription} onChange={e => dispatch(actions.groups.updateNewGroupDescription(e.target.value))} /></Form> <Form><Form.TextArea placeholder="Group description (optional)..." value={newGroupDescription} onChange={e => onUpdateGroupDescription(e.target.value)} /></Form>
<Checkbox style={{marginTop: 40}} toggle checked={newGroupClosed} label="Make this group a closed group" onChange={(e,c) => dispatch(actions.groups.updateNewGroupClosed(c.checked))} /> <Checkbox style={{marginTop: 40}} toggle checked={newGroupClosed} label="Make this group a closed group" onChange={(e,c) => onUpdateGroupClosed(c.checked)} />
<div style={{ marginLeft: 63, color: 'rgb(150,150,150)' }}> <div style={{ marginLeft: 63, color: 'rgb(150,150,150)' }}>
<p>Closed groups are more restrictive and new members must be invited or approved to join. Members can join non-closed groups without being invited.</p> <p>Closed groups are more restrictive and new members must be invited or approved to join. Members can join non-closed groups without being invited.</p>
</div> </div>
@ -59,7 +52,7 @@ function NewGroup() {
<p>You can add and invite others to join your group after you've created it.</p> <p>You can add and invite others to join your group after you've created it.</p>
<div style={{textAlign: 'right'}}> <div style={{textAlign: 'right'}}>
<Button basic onClick={() => navigate(-1)}>Cancel</Button> <Button basic onClick={history.goBack}>Cancel</Button>
<Button color="teal" icon="check" content="Create group" onClick={createGroup} loading={loading} /> <Button color="teal" icon="check" content="Create group" onClick={createGroup} loading={loading} />
</div> </div>
</Grid.Column> </Grid.Column>
@ -67,4 +60,19 @@ function NewGroup() {
); );
} }
export default NewGroup; const mapStateToProps = state => {
const { loading, newGroupName, newGroupDescription, newGroupClosed } = state.groups;
return { user: state.users.users.filter(u => state.auth.currentUserId === u._id)[0], newGroupName, newGroupDescription, newGroupClosed, loading };
};
const mapDispatchToProps = dispatch => ({
onUpdateGroupLoading: l => dispatch(actions.groups.request(l)),
onReceiveGroup: group => dispatch(actions.groups.receiveGroup(group)),
onUpdateGroupName: n => dispatch(actions.groups.updateNewGroupName(n)),
onUpdateGroupDescription: d => dispatch(actions.groups.updateNewGroupDescription(d)),
onUpdateGroupClosed: c => dispatch(actions.groups.updateNewGroupClosed(c)),
});
const NewGroupContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(NewGroup));
export default NewGroupContainer;

View File

@ -1,29 +1,16 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Input, Divider, Loader, Segment, Card, Dropdown, Button } from 'semantic-ui-react'; import { Input, Divider, Loader, Segment, Card, Dropdown, Button } from 'semantic-ui-react';
import { useSelector, useDispatch } from 'react-redux'; import { withRouter } from 'react-router-dom';
import { useParams } from 'react-router-dom'; import { connect } from 'react-redux';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
import ProjectCard from 'components/includes/ProjectCard'; import ProjectCard from 'components/includes/ProjectCard';
function Projects() { function Projects({ group, myProjects, onReceiveProject, projectFilter, updateProjectFilter }) {
const [loadingProjects, setLoadingProjects] = useState(false); const [loadingProjects, setLoadingProjects] = useState(false);
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const dispatch = useDispatch();
const { id } = useParams();
const { group, myProjects, projectFilter } = useSelector(state => {
let group;
state.groups.groups.forEach((g) => {
if (g._id === id) group = g;
});
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const myProjects = state.projects.projects.filter(p => p.user === user?._id);
const projectFilter = state.groups.projectFilter;
return { group, myProjects, projectFilter };
});
useEffect(() => { useEffect(() => {
setLoadingProjects(true); setLoadingProjects(true);
@ -42,7 +29,7 @@ function Projects() {
if (index > -1) groupVisibility.splice(index, 1); if (index > -1) groupVisibility.splice(index, 1);
else groupVisibility.push(group._id); else groupVisibility.push(group._id);
api.projects.update(project.fullName, { groupVisibility }, updatedProject => { api.projects.update(project.fullName, { groupVisibility }, updatedProject => {
dispatch(actions.projects.receiveProject(updatedProject)); onReceiveProject(updatedProject);
const existingIndex = projects.map(p => p._id).indexOf(updatedProject._id); const existingIndex = projects.map(p => p._id).indexOf(updatedProject._id);
const newProjects = Object.assign([], projects); const newProjects = Object.assign([], projects);
if (index > -1 && existingIndex > -1) newProjects.splice(existingIndex, 1); if (index > -1 && existingIndex > -1) newProjects.splice(existingIndex, 1);
@ -79,7 +66,7 @@ function Projects() {
<AddProject style={{float:'right'}} /> <AddProject style={{float:'right'}} />
</>} </>}
{projects?.length > 0 && {projects?.length > 0 &&
<Input autoFocus style={{float:'right', marginRight: 5}} size='small' icon='search' value={projectFilter} onChange={e => dispatch(actions.groups.updateProjectFilter(e.target.value))} placeholder='Filter projects...' /> <Input autoFocus style={{float:'right', marginRight: 5}} size='small' icon='search' value={projectFilter} onChange={e => updateProjectFilter(e.target.value)} placeholder='Filter projects...' />
} }
<Divider hidden clearing /> <Divider hidden clearing />
@ -101,4 +88,23 @@ function Projects() {
) )
} }
export default Projects; const mapStateToProps = (state, ownProps) => {
const { id } = ownProps.match.params;
let group;
state.groups.groups.forEach((g) => {
if (g._id === id) group = g;
});
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const myProjects = state.projects.projects.filter(p => p.user === user?._id);
const projectFilter = state.groups.projectFilter;
return { user, group, myProjects, projectFilter, loading: state.groups.loading, errorMessage: state.groups.errorMessage };
};
const mapDispatchToProps = dispatch => ({
onReceiveProject: project => dispatch(actions.projects.receiveProject(project)),
updateProjectFilter: f => dispatch(actions.groups.updateProjectFilter(f)),
});
const ProjectsContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(Projects));
export default ProjectsContainer;

View File

@ -1,40 +1,31 @@
import React from 'react'; import React from 'react';
import { Header, Button, Divider, Segment, Form, } from 'semantic-ui-react'; import { Header, Button, Divider, Segment, Form, } from 'semantic-ui-react';
import { useNavigate, useParams } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
function Settings() { function Settings({ user, group, loading, onUpdateGroupLoading, onUpdateGroup, onDeleteGroup, history }) {
const navigate = useNavigate();
const dispatch = useDispatch();
const { id } = useParams();
const { loading, group } = useSelector(state => {
const { loading } = state.groups;
const group = state.groups.groups.filter(g => g._id === id)[0];
return { loading, group };
});
const saveGroup = () => { const saveGroup = () => {
dispatch(actions.groups.request(true)); onUpdateGroupLoading(true);
const { _id, name, description, closed } = group; const { _id, name, description, closed } = group;
api.groups.update(_id, { name, description, closed }, () => { api.groups.update(_id, { name, description, closed }, () => {
dispatch(actions.groups.request(false)); onUpdateGroupLoading(false);
toast.info('Group updated'); toast.info('Group updated');
}, err => { }, err => {
toast.error(err.message); toast.error(err.message);
dispatch(actions.groups.request(false)); onUpdateGroupLoading(false);
}); });
} }
const deleteGroup = () => { const deleteGroup = () => {
utils.confirm('Really delete this group?', 'You\'ll lose all entries in the group feed and anything else you\'ve added to it.').then(() => { utils.confirm('Really delete this group?', 'You\'ll lose all entries in the group feed and anything else you\'ve added to it.').then(() => {
api.groups.delete(group._id, () => { api.groups.delete(group._id, () => {
toast.info('Group deleted'); toast.info('Group deleted');
dispatch(actions.groups.deleteGroup(group._id)); onDeleteGroup(group._id);
navigate(`/`); history.push(`/`);
}, err => toast.error(err.message)); }, err => toast.error(err.message));
}, () => {}); }, () => {});
} }
@ -44,9 +35,9 @@ function Settings() {
<Segment color='blue'> <Segment color='blue'>
<Header>About this group</Header> <Header>About this group</Header>
<Form> <Form>
<Form.Input label='Group name' value={group.name} onChange={e => dispatch(actions.groups.updateGroup(group._id, { name: e.target.value }))} /> <Form.Input label='Group name' value={group.name} onChange={e => onUpdateGroup(group._id, { name: e.target.value })} />
<Form.TextArea label='Group description' value={group.description} onChange={e => dispatch(actions.groups.updateGroup(group._id, { description: e.target.value }))}/> <Form.TextArea label='Group description' value={group.description} onChange={e => onUpdateGroup(group._id, { description: e.target.value })}/>
<Form.Checkbox toggle checked={group.closed} label="Closed group" onChange={(e,c) => dispatch(actions.groups.updateGroup(group._id, { closed: c.checked }))} /> <Form.Checkbox toggle checked={group.closed} label="Closed group" onChange={(e,c) => onUpdateGroup(group._id, { closed: c.checked })} />
<div style={{ marginLeft: 63, color: 'rgb(150,150,150)' }}> <div style={{ marginLeft: 63, color: 'rgb(150,150,150)' }}>
<p>Closed groups are more restrictive and new members must be invited or approved to join. Members can join non-closed groups without being invited.</p> <p>Closed groups are more restrictive and new members must be invited or approved to join. Members can join non-closed groups without being invited.</p>
</div> </div>
@ -65,4 +56,17 @@ function Settings() {
) )
} }
export default Settings; const mapStateToProps = state => {
const { loading } = state.groups;
return { loading };
};
const mapDispatchToProps = dispatch => ({
onUpdateGroup: (id, update) => dispatch(actions.groups.updateGroup(id, update)),
onUpdateGroupLoading: l => dispatch(actions.groups.request(l)),
onDeleteGroup: id => dispatch(actions.groups.deleteGroup(id)),
});
const SettingsContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(Settings));
export default SettingsContainer;

View File

@ -1,9 +1,9 @@
import React, { useState } from 'react'; import React, { Component } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Icon, Form, Message, Grid, Input, Button, Divider } from 'semantic-ui-react'; import { Icon, Form, Message, Grid, Input, Button, Divider } from 'semantic-ui-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useNavigate } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux'; import { connect } from 'react-redux';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
@ -11,103 +11,113 @@ import api from 'api';
import UserChip from 'components/includes/UserChip'; import UserChip from 'components/includes/UserChip';
import HelpLink from 'components/includes/HelpLink'; import HelpLink from 'components/includes/HelpLink';
function NewProject() { class NewProject extends Component {
const [name, setName] = useState('My new project'); constructor(props) {
const [description, setDescription] = useState(''); super(props);
const [visibility, setVisibility] = useState('public'); this.state = {
const [openSource, setOpenSource] = useState(true); name: 'My new project', description: '', visibility: 'public', openSource: true, groupVisibility: [], error: '', loading: false,
const [groupVisibility, setGroupVisibility] = useState([]); };
const [error, setError] = useState(''); }
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const dispatch = useDispatch();
const { user, groups } = useSelector(state => { updateName = (event) => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]; this.setState({ name: event.target.value });
const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id)); }
return { user, groups };
});
const updateName = (event) => { changeVisibility = (event, r) => {
setName(event.target.value); this.setState({ visibility: r.checked ? 'private' : 'public' });
}; }
const changeVisibility = (event, r) => { changeOpenSource = (event, c) => {
setVisibility(r.checked ? 'private' : 'public'); this.setState({ openSource: c.checked });
}; }
const changeOpenSource = (event, c) => { createProject = () => {
setOpenSource(c.checked); this.setState({ loading: true });
}; const { name, description, visibility, openSource, groupVisibility } = this.state;
const createProject = () => {
setLoading(true);
api.projects.create({ name, description, visibility, openSource, groupVisibility }, (project) => { api.projects.create({ name, description, visibility, openSource, groupVisibility }, (project) => {
dispatch(actions.projects.receiveProject(project)); this.props.onReceiveProject(project);
setLoading(false); this.setState({ loading: false });
navigate(`/${user.username}/${project.path}`); this.props.history.push(`/${this.props.user.username}/${project.path}`);
}, (err) => { }, (err) => {
setLoading(false); this.setState({ loading: false });
toast.error(err.message); toast.error(err.message);
setError(err.message);
}); });
}; }
return ( render() {
<Grid stackable centered> const {
<Helmet title={'Create Project'} /> name, description, visibility, openSource, groupVisibility
<Grid.Column computer={8}> } = this.state;
<h2 style={{ marginTop: 40 }}> const { user, groups } = this.props;
<Icon name="book" /> return (
{' '} <Grid stackable centered>
<Helmet title={'Create Project'} />
<Grid.Column computer={8}>
<h2 style={{ marginTop: 40 }}>
<Icon name="book" />
{' '}
Create a new project Create a new project
</h2> </h2>
<p>This will contain all of the files and patterns for your project.</p> <p>This will contain all of the files and patterns for your project.</p>
<HelpLink link={`${process.env.REACT_APP_SUPPORT_ROOT}Projects#creating-a-new-project`} /> <HelpLink link={`${process.env.REACT_APP_SUPPORT_ROOT}Projects#creating-a-new-project`} />
<Divider section /> <Divider section />
<h3>About your project</h3> <h3>About your project</h3>
<p>Give your project a short name. You can always rename it later on.</p> <p>Give your project a short name. You can always rename it later on.</p>
<Input autoFocus type="text" fluid onChange={updateName} value={name} label={<UserChip user={user} style={{ marginRight: 10, marginTop: 4 }} />} /> <Input autoFocus type="text" fluid onChange={this.updateName} value={name} label={<UserChip user={user} style={{ marginRight: 10, marginTop: 4 }} />} />
<Divider hidden />
<p>Write a project description (optional).</p>
<Form><Form.TextArea placeholder="Project description (optional)..." value={description} onChange={e => setDescription(e.target.value)} /></Form>
<Divider section />
<h3>Project visibility</h3>
<Form>
<Form.Checkbox label='This is a private project' checked={visibility === 'private'} onChange={changeVisibility} />
<p style={{color:'rgb(150,150,150)'}}><Icon name='info' /> Private projects are not searchable and can't be seen by others, unless you give them access.</p>
<Form.Checkbox label='This project is "open-source"' checked={openSource} onChange={changeOpenSource} />
<p style={{color:'rgb(150,150,150)'}}><Icon name='info' /> Open-source projects allow other people to download any pattern source files they contain in WIF format.</p>
<Divider hidden /> <Divider hidden />
<p>Write a project description (optional).</p>
<Form><Form.TextArea placeholder="Project description (optional)..." value={description} onChange={e => this.setState({ description: e.target.value })} /></Form>
<Divider section />
{groups?.length > 0 && <h3>Project visibility</h3>
<> <Form>
<h4>Make visible to your groups</h4> <Form.Checkbox label='This is a private project' checked={visibility === 'private'} onChange={this.changeVisibility} />
<p>You can make this project visible to groups you are a member of - even if it is a private project. The selected groups will also list this project on their Projects tabs.</p> <p style={{color:'rgb(150,150,150)'}}><Icon name='info' /> Private projects are not searchable and can't be seen by others, unless you give them access.</p>
<Form.Checkbox label='This project is "open-source"' checked={openSource} onChange={this.changeOpenSource} />
<p style={{color:'rgb(150,150,150)'}}><Icon name='info' /> Open-source projects allow other people to download any pattern source files they contain in WIF format.</p>
<Divider hidden />
<Form.Select multiple {groups?.length > 0 &&
label='Make this project always visible to members of these groups' <>
value={groupVisibility} <h4>Make visible to your groups</h4>
options={groups.map(g => ({ key: g._id, value: g._id, text: g.name }))} <p>You can make this project visible to groups you are a member of - even if it is a private project. The selected groups will also list this project on their Projects tabs.</p>
onChange={(e, s) => setGroupVisibility(s.value)}
/>
</>
}
</Form>
<Divider section /> <Form.Select multiple
label='Make this project always visible to members of these groups'
value={groupVisibility}
options={groups.map(g => ({ key: g._id, value: g._id, text: g.name }))}
onChange={(e, s) => this.setState({ groupVisibility: s.value })}
/>
</>
}
</Form>
{error && <Message color="orange" content={error} />} <Divider section />
<div style={{textAlign:'right'}}>
<Button basic onClick={() => navigate(-1)}>Cancel</Button> {this.state.error && <Message color="orange" content={this.state.error} />}
<Button color="teal" icon="check" content="Create project" onClick={createProject} loading={loading} /> <div style={{textAlign:'right'}}>
</div> <Button basic onClick={this.props.history.goBack}>Cancel</Button>
</Grid.Column> <Button color="teal" icon="check" content="Create project" onClick={this.createProject} loading={this.state.loading} />
</Grid> </div>
); </Grid.Column>
</Grid>
);
}
} }
export default NewProject;
const mapStateToProps = state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
return { user, groups };
};
const mapDispatchToProps = dispatch => ({
onReceiveProject: project => dispatch(actions.projects.receiveProject(project)),
});
const NewProjectContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(NewProject));
export default NewProjectContainer;

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Segment, Label, Input, Icon, Card, Loader } from 'semantic-ui-react'; import { Segment, Label, Input, Icon, Card, Loader } from 'semantic-ui-react';
import { Link, useParams } from 'react-router-dom'; import { Link, withRouter } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
import actions from 'actions'; import actions from 'actions';
@ -22,31 +22,17 @@ const CompactObject = styled(Link)`
} }
`; `;
function ObjectList({ compact }) { function ObjectList({ user, objects, currentObject, project, fullProjectPath, onReceiveObjects, compact }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [objectFilter, setObjectFilter] = useState(''); const [objectFilter, setObjectFilter] = useState('');
const { username, projectPath, objectId } = useParams();
const dispatch = useDispatch();
const { user, project, objects, currentObject, fullProjectPath } = useSelector(state => {
const project = state.projects.projects.filter(p => p.path === projectPath && p.owner && p.owner.username === username)[0];
const objects = [];
let currentObject;
state.objects.objects.forEach((d) => {
if (d.project === project._id) objects.push(d);
if (d._id === objectId) currentObject = d;
});
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user, project, objects, currentObject, fullProjectPath: `${username}/${projectPath}` };
});
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
api.projects.getObjects(fullProjectPath, o => { api.projects.getObjects(fullProjectPath, projects => {
dispatch(actions.objects.receiveMultiple(o)); onReceiveObjects(projects);
setLoading(false); setLoading(false);
}, err => setLoading(false)); }, err => setLoading(false));
}, [fullProjectPath, dispatch]); }, [fullProjectPath, onReceiveObjects]);
let filteredObjects = objects; let filteredObjects = objects;
if (objectFilter) { if (objectFilter) {
@ -171,4 +157,26 @@ function ObjectList({ compact }) {
); );
} }
export default ObjectList;
const mapStateToProps = (state, ownProps) => {
const { username, projectPath, objectId } = ownProps.match.params;
const project = state.projects.projects.filter(p => p.path === projectPath && p.owner && p.owner.username === username)[0];
const objects = [];
let currentObject;
state.objects.objects.forEach((d) => {
if (d.project === project._id) objects.push(d);
if (d._id === objectId) currentObject = d;
});
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user, project, objects, currentObject, fullProjectPath: `${username}/${projectPath}` };
};
const mapDispatchToProps = dispatch => ({
onReceiveObjects: objects => dispatch(actions.objects.receiveMultiple(objects)),
onEditObject: (id, field, value) => dispatch(actions.objects.update(id, field, value)),
onDeleteObject: id => dispatch(actions.objects.delete(id)),
});
const ObjectListContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(ObjectList));
export default ObjectListContainer;

View File

@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Input, Message, Icon, Button, Dropdown } from 'semantic-ui-react'; import { Input, Message, Icon, Button, Dropdown } from 'semantic-ui-react';
import { useSelector, useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, withRouter } from 'react-router-dom';
import moment from 'moment'; import moment from 'moment';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
import actions from 'actions'; import actions from 'actions';
@ -15,42 +15,23 @@ import DraftPreview from './objects/DraftPreview';
import NewFeedMessage from 'components/includes/NewFeedMessage'; import NewFeedMessage from 'components/includes/NewFeedMessage';
import FeedMessage from 'components/includes/FeedMessage'; import FeedMessage from 'components/includes/FeedMessage';
function ObjectViewer() { function ObjectViewer({ user, myProjects, project, fullProjectPath, onEditObject, onDeleteObject, object, comments, history, onReceiveComment, onDeleteComment }) {
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [editingDescription, setEditingDescription] = useState(false); const [editingDescription, setEditingDescription] = useState(false);
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const navigate = useNavigate(); const objectId = object?._id;
const { username, projectPath, objectId } = useParams();
const dispatch = useDispatch();
const { user, myProjects, project, fullProjectPath, object, comments } = useSelector(state => {
const project = state.projects.projects.filter(p => p.path === projectPath && p.owner && p.owner.username === username)[0];
const objects = [];
state.objects.objects.forEach((d) => {
if (d.project === project._id) objects.push(d);
});
const object = objects.filter(o => o._id === objectId)[0];
const comments = state.objects.comments?.filter(c => c.object === object?._id).sort((a, b) => {
const aDate = new Date(a.createdAt);
const bDate = new Date(b.createdAt);
return aDate < bDate;
});
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const myProjects = state.projects.projects.filter(p => p.owner?.username === user?.username);
return { user, myProjects, project, fullProjectPath: `${username}/${projectPath}`, object, comments };
});
useEffect(() => { useEffect(() => {
if (!objectId) return; if (!objectId) return;
api.objects.getComments(objectId, data => { api.objects.getComments(objectId, data => {
data.comments.forEach(c => dispatch(actions.objects.receiveComment(c))); data.comments.forEach(onReceiveComment);
}); });
}, [dispatch, objectId]); }, [objectId, onReceiveComment]);
const deleteObject = (object) => { const deleteObject = (object) => {
utils.confirm('Delete object', 'Really delete this object? This cannot be undone.').then(() => { utils.confirm('Delete object', 'Really delete this object? This cannot be undone.').then(() => {
navigate(`/${fullProjectPath}`); history.push(`/${fullProjectPath}`);
api.objects.delete(object._id, () => dispatch(actions.objects.delete(object._id)), err => toast.error(err.message)); api.objects.delete(object._id, () => onDeleteObject(object._id), err => toast.error(err.message));
}, () => {}); }, () => {});
} }
@ -165,7 +146,7 @@ function ObjectViewer() {
{editingName ? {editingName ?
<div style={{marginBottom: 5}}> <div style={{marginBottom: 5}}>
<Input autoFocus size="small" value={object.name} style={{marginBottom: 10}} <Input autoFocus size="small" value={object.name} style={{marginBottom: 10}}
onChange={e => dispatch(actions.objects.update(object._id, 'name', e.target.value))} onChange={e => onEditObject(object._id, 'name', e.target.value)}
action=<Button icon="check" primary onClick={e => saveObjectName(object)} /> action=<Button icon="check" primary onClick={e => saveObjectName(object)} />
/> />
</div> </div>
@ -185,7 +166,7 @@ function ObjectViewer() {
<div style={{marginTop: 20, marginBottom: 20, padding: 10, border: '1px solid rgb(240,240,240)'}}> <div style={{marginTop: 20, marginBottom: 20, padding: 10, border: '1px solid rgb(240,240,240)'}}>
{object.type === 'pattern' && {object.type === 'pattern' &&
<div style={{maxHeight: 400, overflowY: 'scroll'}}> <div style={{maxHeight: 400, overflowY: 'scroll'}}>
<DraftPreview object={object} /> <DraftPreview object={object} onImageLoaded={i => onEditObject(object._id, 'patternImage', i)}/>
</div> </div>
} }
{object.isImage && {object.isImage &&
@ -201,7 +182,7 @@ function ObjectViewer() {
{editingDescription ? {editingDescription ?
<div style={{padding: 10, border: '1px solid rgb(230,230,230)'}}> <div style={{padding: 10, border: '1px solid rgb(230,230,230)'}}>
<RichText value={object.description} onChange={t => dispatch(actions.objects.update(object._id, 'description', t))} /> <RichText value={object.description} onChange={t => onEditObject(object._id, 'description', t)} />
<Button primary size="small" icon="check" content="Save description" onClick={e => saveObjectDescription(object)} /> <Button primary size="small" icon="check" content="Save description" onClick={e => saveObjectDescription(object)} />
</div> </div>
: :
@ -221,14 +202,42 @@ function ObjectViewer() {
} }
</h3> </h3>
{user ? {user ?
<NewFeedMessage noAttachments user={user} forType='object' object={object} onPosted={c => dispatch(actions.objects.receiveComment(c))} placeholder='Write a new comment...'/> <NewFeedMessage noAttachments user={user} forType='object' object={object} onPosted={onReceiveComment} placeholder='Write a new comment...'/>
: <Message size='small'>Please login to write your own comments.</Message> : <Message size='small'>Please login to write your own comments.</Message>
} }
{comments?.map(c => {comments?.map(c =>
<FeedMessage key={c._id} user={user} forType='object' object={object} post={c} replies={[]} onDeleted={id => dispatch(actions.objects.deleteComment(id))} /> <FeedMessage key={c._id} user={user} forType='object' object={object} post={c} replies={[]} onDeleted={onDeleteComment} />
)} )}
</div> </div>
</> </>
); );
} }
export default ObjectViewer;
const mapStateToProps = (state, ownProps) => {
const { username, projectPath, objectId } = ownProps.match.params;
const project = state.projects.projects.filter(p => p.path === projectPath && p.owner && p.owner.username === username)[0];
const objects = [];
state.objects.objects.forEach((d) => {
if (d.project === project._id) objects.push(d);
});
const object = objects.filter(o => o._id === objectId)[0];
const comments = state.objects.comments?.filter(c => c.object === object?._id).sort((a, b) => {
const aDate = new Date(a.createdAt);
const bDate = new Date(b.createdAt);
return aDate < bDate;
});
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const myProjects = state.projects.projects.filter(p => p.owner?.username === user?.username);
return { user, myProjects, project, objects, fullProjectPath: `${username}/${projectPath}`, object, comments };
};
const mapDispatchToProps = dispatch => ({
onEditObject: (id, field, value) => dispatch(actions.objects.update(id, field, value)),
onDeleteObject: id => dispatch(actions.objects.delete(id)),
onReceiveComment: c => dispatch(actions.objects.receiveComment(c)),
onDeleteComment: id => dispatch(actions.objects.deleteComment(id)),
});
const ObjectViewerContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(ObjectViewer));
export default ObjectViewerContainer;

View File

@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Message, Form, TextArea, Container, Button, Icon, Grid, Card } from 'semantic-ui-react'; import { Message, Form, TextArea, Container, Button, Icon, Grid, Card } from 'semantic-ui-react';
import { Outlet, Link, useParams, useLocation } from 'react-router-dom'; import { Switch, Route, Link, withRouter } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux'; import { connect } from 'react-redux';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
@ -11,28 +11,24 @@ import UserChip from 'components/includes/UserChip';
import HelpLink from 'components/includes/HelpLink'; import HelpLink from 'components/includes/HelpLink';
import ObjectCreator from 'components/includes/ObjectCreator'; import ObjectCreator from 'components/includes/ObjectCreator';
import FormattedMessage from 'components/includes/FormattedMessage'; import FormattedMessage from 'components/includes/FormattedMessage';
import Draft from './objects/Draft.js';
import ObjectList from './ObjectList.js';
import ProjectObjects from './ProjectObjects.js';
import ProjectSettings from './Settings.js';
function Project() { function Project({ user, project, fullName, errorMessage, editingDescription, onUpdateProject, onReceiveProject, onEditDescription, onRequest, onRequestFailed, match, history }) {
const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const { username, projectPath } = useParams();
const dispatch = useDispatch();
const location = useLocation();
const { user, project, fullName, errorMessage, editingDescription } = useSelector(state => {
const project = state.projects.projects.filter(p => p.path === projectPath && p.owner && p.owner.username === username)[0];
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user, project, fullName: `${username}/${projectPath}`, errorMessage: state.projects.errorMessage, editingDescription: state.projects.editingDescription };
});
useEffect(() => { useEffect(() => {
dispatch(actions.projects.request()); onRequest();
api.projects.get(fullName, p => dispatch(actions.projects.receiveProject(p)), err => dispatch(actions.projects.requestFailed(err))); api.projects.get(fullName, onReceiveProject, onRequestFailed);
}, [user, dispatch, fullName]); }, [user, onRequest, fullName, onReceiveProject, onRequestFailed]);
const wideBody = () => !location.pathname.toLowerCase().endsWith(fullName.toLowerCase()); const wideBody = () => !match.isExact
const saveDescription = () => { const saveDescription = () => {
dispatch(actions.projects.editDescription(false)); onEditDescription(false);
api.projects.update(fullName, { description: project.description }, p => dispatch(actions.projects.receiveProject(p))); api.projects.update(fullName, { description: project.description }, onReceiveProject);
} }
const getDescription = () => { const getDescription = () => {
@ -54,11 +50,11 @@ function Project() {
{project {project
&& ( && (
<div> <div>
{/*history.location?.state?.prevPath && {history.location?.state?.prevPath &&
<div style={{marginBottom:15}}> <div style={{marginBottom:15}}>
<Button basic secondary onClick={e => history.goBack()} icon='arrow left' content='Go back' /> <Button basic secondary onClick={e => history.goBack()} icon='arrow left' content='Go back' />
</div> </div>
*/} }
{wideBody() && project.owner && {wideBody() && project.owner &&
<> <>
@ -89,7 +85,7 @@ function Project() {
{editingDescription {editingDescription
? ( ? (
<Form> <Form>
<TextArea style={{ marginBottom: '5px' }} placeholder="Describe this project..." value={project.description} onChange={e => dispatch(actions.projects.updateProject(project._id, { description: e.target.value }))} /> <TextArea style={{ marginBottom: '5px' }} placeholder="Describe this project..." value={project.description} onChange={e => onUpdateProject(project._id, { description: e.target.value })} />
<Button size="tiny" color="green" fluid onClick={saveDescription}>Save description</Button> <Button size="tiny" color="green" fluid onClick={saveDescription}>Save description</Button>
</Form> </Form>
) )
@ -104,7 +100,7 @@ function Project() {
</div> </div>
} }
{utils.canEditProject(user, project) && ( {utils.canEditProject(user, project) && (
<Button size='mini' fluid className="right floated" onClick={e => dispatch(actions.projects.editDescription(true))}> <Button size='mini' fluid className="right floated" onClick={e => onEditDescription(true)}>
<Icon name="pencil" /> {project.description ? 'Edit' : 'Add a project'} description <Icon name="pencil" /> {project.description ? 'Edit' : 'Add a project'} description
</Button> </Button>
) )
@ -125,7 +121,14 @@ function Project() {
)} )}
<Grid.Column computer={wideBody() ? 16 : 12} tablet={wideBody() ? 16 : 10}> <Grid.Column computer={wideBody() ? 16 : 12} tablet={wideBody() ? 16 : 10}>
{project && <Outlet />} {project && (
<Switch>
<Route path="/:username/:projectPath/settings" component={ProjectSettings} />
<Route path="/:username/:projectPath/:objectId/edit" component={Draft} />
<Route path="/:username/:projectPath/:objectId" component={ProjectObjects} />
<Route path="/:username/:projectPath" component={ObjectList} />
</Switch>
) }
</Grid.Column> </Grid.Column>
</Grid> </Grid>
@ -137,4 +140,22 @@ function Project() {
); );
} }
export default Project; const mapStateToProps = (state, ownProps) => {
const { username, projectPath } = ownProps.match.params;
const project = state.projects.projects.filter(p => p.path === projectPath && p.owner && p.owner.username === username)[0];
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user, project, fullName: `${username}/${projectPath}`, loading: state.projects.loading, errorMessage: state.projects.errorMessage, editingDescription: state.projects.editingDescription };
};
const mapDispatchToProps = dispatch => ({
onRequest: () => dispatch(actions.projects.request()),
onRequestFailed: err => dispatch(actions.projects.requestFailed(err)),
onEditDescription: e => dispatch(actions.projects.editDescription(e)),
onReceiveProject: project => dispatch(actions.projects.receiveProject(project)),
onSelectObject: id => dispatch(actions.objects.select(id)),
onUpdateProject: (id, update) => dispatch(actions.projects.updateProject(id, update)),
});
const ProjectContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(Project));
export default ProjectContainer;

View File

@ -1,122 +1,134 @@
import React, { useState } from 'react'; import React, { Component } from 'react';
import { Form, Input, Divider, Segment, Button, Icon } from 'semantic-ui-react'; import { Form, Input, Divider, Segment, Button, Icon } from 'semantic-ui-react';
import { useNavigate, useParams } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useSelector, useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
import HelpLink from 'components/includes/HelpLink'; import HelpLink from 'components/includes/HelpLink';
class ProjectSettings extends Component {
function ProjectSettings() { constructor(props) {
const { username, projectPath } = useParams(); super(props);
const { groups, project, fullProjectPath } = useSelector(state => { this.state = { name: props.project.name, visibility: props.project.visibility || 'public', openSource: props.project.openSource, groupVisibility: props.project.groupVisibility || [] };
const project = state.projects.projects.filter(p => p.path === projectPath && p.owner && p.owner.username === username)[0];
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
return { groups, project, fullProjectPath: `${username}/${projectPath}` };
});
const [name, setName] = useState(project.name);
const [visibility, setVisibility] = useState(project.visibility || 'public');
const [groupVisibility, setGroupVisibility] = useState(project.groupVisibility || []);
const [openSource, setOpenSource] = useState(project.openSource || true);
const navigate = useNavigate();
const dispatch = useDispatch();
const changeVisibility = (event, r) => {
setVisibility(r.checked ? 'private' : 'public');
};
const changeOpenSource = (event, c) => {
setOpenSource(c.checked);
};
const saveName = () => {
utils.confirm('Update name', 'Really update this project\'s name? If you\'ve shared this project\'s link with someone, they may no longer be able to find it.').then(() => {
api.projects.update(fullProjectPath, { name: name }, (project) => {
dispatch(actions.projects.receiveProject(project));
navigate(`/${project.owner.username}/${project.path}`);
}, err => toast.error(err.message));
});
};
const saveVisibility = () => {
api.projects.update(project.fullName, { visibility, openSource, groupVisibility }, (p) => {
dispatch(actions.projects.receiveProject(p));
toast.info('Visibility saved');
}, err => toast.error(err.message));
};
const deleteProject = () => {
utils.confirm('Delete project', 'Really delete this project? All files and patterns it contains will also be deleted. This action cannot be undone.').then(() => {
api.projects.delete(fullProjectPath, () => {
dispatch(actions.projects.deleteProject(project._id));
toast.info('🗑️ Project deleted');
navigate('/');
}, err => toast.error(err.message));
});
} }
return ( changeVisibility = (event, r) => {
<div> this.setState({ visibility: r.checked ? 'private' : 'public' });
<h2>Project settings</h2> }
<HelpLink link={`${process.env.REACT_APP_SUPPORT_ROOT}Projects#changing-the-project-s-settings`} /> changeOpenSource = (event, c) => {
this.setState({ openSource: c.checked });
}
<Divider hidden section /> saveName = () => {
utils.confirm('Update name', 'Really update this project\'s name? If you\'ve shared this project\'s link with someone, they may no longer be able to find it.').then(() => {
api.projects.update(this.props.fullProjectPath, { name: this.state.name }, (project) => {
this.props.onReceiveProject(project);
this.props.history.push(`/${project.owner.username}/${project.path}`);
}, err => toast.error(err.message));
}, () => {});
}
<Segment> saveVisibility = () => {
<h3>General settings</h3> api.projects.update(this.props.project.fullName, { visibility: this.state.visibility, openSource: this.state.openSource, groupVisibility: this.state.groupVisibility }, (project) => {
this.props.onReceiveProject(project);
toast.info('Visibility saved');
}, err => toast.error(err.message));
}
<h4>Project name</h4> deleteProject = () => {
<p>Changing the name of your project will also change its URL. If you have previously shared a link to your project, then visitors may not be able to find it if the name is changed.</p> utils.confirm('Delete project', 'Really delete this project? All files and patterns it contains will also be deleted. This action cannot be undone.').then(() => {
<Input api.projects.delete(this.props.fullProjectPath, () => {
type="text" value={name} this.props.onDeleteProject(this.props.project._id);
action=<Button color="teal" content="Update" onClick={saveName} /> toast.info('🗑️ Project deleted');
onChange={e => setName(e.target.value)} this.props.history.push('/');
/> this.setState({ nameError: '' });
</Segment> }, err => toast.error(err.message));
}, () => {});
}
<Divider hidden /> render() {
const { name, visibility, openSource } = this.state;
const { groups } = this.props;
return (
<div>
<h2>Project settings</h2>
<Segment color="yellow"> <HelpLink link={`${process.env.REACT_APP_SUPPORT_ROOT}Projects#changing-the-project-s-settings`} />
<h3>Project visibility</h3>
<Form>
<Form.Checkbox label='This is a private project' checked={visibility === 'private'} onChange={changeVisibility} />
<p style={{color:'rgb(150,150,150)'}}><Icon name='info' /> Private projects are not searchable and can't be seen by others, unless you give them access.</p>
<Form.Checkbox label='This project is "open-source"' checked={openSource} onChange={changeOpenSource} />
<p style={{color:'rgb(150,150,150)'}}><Icon name='info' /> Open-source projects allow other people to download any pattern source files they contain in WIF format.</p>
<Divider hidden />
{groups?.length > 0 && <Divider hidden section />
<>
<h4>Make visible to your groups</h4> <Segment>
<p>You can make this project visible to groups you are a member of - even if it is a private project. The selected groups will also list this project on their Projects tabs.</p> <h3>General settings</h3>
<h4>Project name</h4>
<p>Changing the name of your project will also change its URL. If you have previously shared a link to your project, then visitors may not be able to find it if the name is changed.</p>
<Input
type="text" value={name}
action=<Button color="teal" content="Update" onClick={this.saveName} />
onChange={e => this.setState({ name: e.target.value })}
/>
</Segment>
<Form.Select multiple
label='Make this project always visible to members of these groups'
value={groupVisibility}
options={groups.map(g => ({ key: g._id, value: g._id, text: g.name }))}
onChange={(e, s) => setGroupVisibility(s.value)}
/>
</>
}
</Form>
<Divider hidden /> <Divider hidden />
<Button color="teal" content="Save visibility" onClick={saveVisibility} /> <Segment color="yellow">
</Segment> <h3>Project visibility</h3>
<Form>
<Form.Checkbox label='This is a private project' checked={visibility === 'private'} onChange={this.changeVisibility} />
<p style={{color:'rgb(150,150,150)'}}><Icon name='info' /> Private projects are not searchable and can't be seen by others, unless you give them access.</p>
<Form.Checkbox label='This project is "open-source"' checked={openSource} onChange={this.changeOpenSource} />
<p style={{color:'rgb(150,150,150)'}}><Icon name='info' /> Open-source projects allow other people to download any pattern source files they contain in WIF format.</p>
<Divider hidden />
<Divider hidden section /> {groups?.length > 0 &&
<>
<h4>Make visible to your groups</h4>
<p>You can make this project visible to groups you are a member of - even if it is a private project. The selected groups will also list this project on their Projects tabs.</p>
<Segment color="red"> <Form.Select multiple
<h3>Delete project</h3> label='Make this project always visible to members of these groups'
<p>Immediately and irreversibly delete this project, along with all of its contents.</p> value={this.state.groupVisibility}
<Button icon="trash" content="Delete project" color="red" onClick={deleteProject} /> options={groups.map(g => ({ key: g._id, value: g._id, text: g.name }))}
</Segment> onChange={(e, s) => this.setState({ groupVisibility: s.value })}
</div> />
); </>
}
</Form>
<Divider hidden />
<Button color="teal" content="Save visibility" onClick={this.saveVisibility} />
</Segment>
<Divider hidden section />
<Segment color="red">
<h3>Delete project</h3>
<p>Immediately and irreversibly delete this project, along with all of its contents.</p>
<Button icon="trash" content="Delete project" color="red" onClick={this.deleteProject} />
</Segment>
</div>
);
}
} }
export default ProjectSettings;
const mapStateToProps = (state, ownProps) => {
const { username, projectPath } = ownProps.match.params;
const project = state.projects.projects.filter(p => p.path === projectPath && p.owner && p.owner.username === username)[0];
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
return { user, groups, project, fullProjectPath: `${username}/${projectPath}` };
};
const mapDispatchToProps = dispatch => ({
onDeleteProject: id => dispatch(actions.projects.deleteProject(id)),
onReceiveProject: project => dispatch(actions.projects.receiveProject(project)),
onUpdateProject: (id, update) => dispatch(actions.projects.updateProject(id, update)),
});
const ProjectSettingsContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(ProjectSettings));
export default ProjectSettingsContainer;

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { Component } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useSelector, useDispatch } from 'react-redux'; import { withRouter, Prompt } from 'react-router-dom';
import { useParams } from 'react-router-dom'; import { connect } from 'react-redux';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import styled from 'styled-components'; import styled from 'styled-components';
import ElementPan from 'components/includes/ElementPan'; import ElementPan from 'components/includes/ElementPan';
@ -25,91 +25,104 @@ export const StyledPattern = styled.div`
margin:20px; margin:20px;
`; `;
function Draft() { class Draft extends Component {
const [unsaved, setUnsaved] = useState(false); constructor(props) {
const [saving, setSaving] = useState(false); super(props);
const [object, setObject] = useState(); this.state = { unsaved: false, saving: false };
const [pattern, setPattern] = useState(); }
const [name] = useState();
const { objectId } = useParams();
const dispatch = useDispatch();
const { editor } = useSelector(state => ({ editor: state.objects.editor }));
useEffect(() => { componentDidMount() {
api.objects.get(objectId, (o) => { api.objects.get(this.props.match.params.objectId, (object) => {
if (!o.pattern.baseSize) o.pattern.baseSize = 10; if (!object.pattern.baseSize) object.pattern.baseSize = 10;
setObject(o); this.setState(object);
setPattern(o.pattern);
}); });
}, [objectId]); }
const updateObject = (update) => { updateObject = (update) => {
setObject(Object.assign({}, object, update)); this.setState(Object.assign({}, this.state, update));
setUnsaved(true); this.setState({ unsaved: true });
}; }
const updatePattern = (update) => { updatePattern = (update) => {
const newPattern = Object.assign({}, pattern, update); const newPattern = Object.assign({}, this.state.pattern, update);
setPattern(Object.assign({}, pattern, newPattern)); this.setState(Object.assign({}, this.state, { pattern: newPattern }));
setUnsaved(true); this.setState({ unsaved: true });
}; }
const saveObject = () => { saveObject = () => {
setSaving(true); this.setState({ saving: true });
const canvas = document.getElementsByClassName('drawdown')[0]; const canvas = document.getElementsByClassName('drawdown')[0];
const newObject = Object.assign({}, object); const object = Object.assign({}, this.state);
newObject.preview = canvas.toDataURL(); object.preview = canvas.toDataURL();
api.objects.update(objectId, newObject, (o) => { api.objects.update(this.props.match.params.objectId, object, (o) => {
toast.success('Pattern saved'); toast.success('Pattern saved');
dispatch(actions.objects.receive(o)); this.props.onReceiveObject(o);
setUnsaved(false); this.setState({ unsaved: false, saving: false });
setSaving(false);
}, (err) => { }, (err) => {
toast.error(err.message); toast.error(err.message);
setSaving(false); this.setState({ saving: false });
}); });
}; }
if (!pattern) return null; rerunTour = () => {
const { warp, weft, tieups, baseSize } = pattern;
const cellStyle = { width: `${baseSize || 10}px`, height: `${baseSize || 10}px` };
return (
<div>
<Helmet title={`${name || 'Weaving Draft'}`} />
<Tour id='pattern' run={true} />
<div style={{display: 'flex'}}>
<div style={{flex: 1, overflow: 'hidden'}}> }
<ElementPan
disabled={!(editor?.tool === 'pan')} render() {
startX={5000} if (!this.state.pattern) return null;
startY={0} const { unsaved, saving } = this.state;
> const { warp, weft, tieups, baseSize } = this.state.pattern;
<StyledPattern const cellStyle = { width: `${baseSize || 10}px`, height: `${baseSize || 10}px` };
style={{ return (
width: `${warp.threads * baseSize + weft.treadles * baseSize + 20}px`, <div>
height: '1000px', // `${warp.shafts * baseSize + weft.threads * baseSize + 20}px` <Helmet title={`${this.state?.name || 'Weaving Draft'}`} />
}} <Prompt
when={unsaved ? true : false}
message='You have unsaved changes. Are you sure you want to leave tnis page?'
/>
<Tour id='pattern' run={true} />
<div style={{display: 'flex'}}>
<div style={{flex: 1, overflow: 'hidden'}}>
<ElementPan
disabled={!(this.props.editor && this.props.editor.tool === 'pan')}
startX={5000}
startY={0}
> >
<StyledPattern
style={{
width: `${warp.threads * baseSize + weft.treadles * baseSize + 20}px`,
height: '1000px', // `${warp.shafts * baseSize + weft.threads * baseSize + 20}px`
}}
>
<Warp baseSize={baseSize} cellStyle={cellStyle} warp={warp} weft={weft} updatePattern={updatePattern} /> <Warp baseSize={baseSize} cellStyle={cellStyle} warp={warp} weft={weft} updatePattern={this.updatePattern} />
<Weft cellStyle={cellStyle} warp={warp} weft={weft} baseSize={baseSize} updatePattern={updatePattern} /> <Weft cellStyle={cellStyle} warp={warp} weft={weft} baseSize={baseSize} updatePattern={this.updatePattern} />
<Tieups cellStyle={cellStyle} warp={warp} weft={weft} tieups={tieups} updatePattern={updatePattern} baseSize={baseSize}/> <Tieups cellStyle={cellStyle} warp={warp} weft={weft} tieups={tieups} updatePattern={this.updatePattern} baseSize={baseSize}/>
<Drawdown warp={warp} weft={weft} tieups={tieups} baseSize={baseSize} /> <Drawdown warp={warp} weft={weft} tieups={tieups} baseSize={baseSize} />
</StyledPattern>
</ElementPan>
</div>
<div style={{width: 300, marginLeft: 20}}>
<HelpLink className='joyride-help' link={`${process.env.REACT_APP_SUPPORT_ROOT}Editing-patterns#using-the-pattern-editor`} marginBottom/>
<ReRunTour id='pattern' />
<Tools warp={warp} weft={weft} object={this.state} pattern={this.state.pattern} updateObject={this.updateObject} updatePattern={this.updatePattern} saveObject={this.saveObject} baseSize={baseSize} unsaved={unsaved} saving={saving}/>
</div>
</StyledPattern>
</ElementPan>
</div> </div>
<div style={{width: 300, marginLeft: 20}}>
<HelpLink className='joyride-help' link={`${process.env.REACT_APP_SUPPORT_ROOT}Editing-patterns#using-the-pattern-editor`} marginBottom/>
<ReRunTour id='pattern' />
<Tools warp={warp} weft={weft} object={object} pattern={pattern} updateObject={updateObject} updatePattern={updatePattern} saveObject={saveObject} baseSize={baseSize} unsaved={unsaved} saving={saving}/>
</div>
</div> </div>
</div> );
); }
} }
export default Draft; const mapStateToProps = (state, ownProps) => ({ editor: state.objects.editor });
const mapDispatchToProps = dispatch => ({
onReceiveObject: object => dispatch(actions.objects.receive(object)),
});
const DraftContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(Draft));
export default DraftContainer;

View File

@ -0,0 +1,52 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import actions from 'actions'; import api from 'api'; import Warp from './Warp.js'; import Weft from './Weft.js';
import Tieups from './Tieups.js';
import Drawdown from './Drawdown.js';
class DraftExport extends Component {
constructor(props) {
super(props);
this.state = { };
}
componentDidMount() {
this.props.onEditorUpdated({ view: 'colour' }); // interlacement, warp, weft
api.objects.get(this.props.match.params.id, (object) => {
if (object.pattern && object.pattern.warp) this.setState(object);
});
}
render() {
if (!this.state.pattern) return null;
const { warp, weft, tieups } = this.state.pattern;
if (!warp || !weft || !tieups) return null;
const baseSize = 6;
const cellStyle = { width: `${baseSize || 10}px`, height: `${baseSize || 10}px` };
return (
<div className="pattern"
style={{
margin: '0px 0px',
width: `${warp.threads * baseSize + weft.treadles * baseSize + 20}px`,
height: `${warp.shafts * baseSize + weft.threads * baseSize + 20}px`,
display: this.props.hidden ? 'none' : 'inherit',
}}
>
<Drawdown warp={warp} weft={weft} tieups={tieups} baseSize={baseSize} />
<Warp baseSize={baseSize} cellStyle={cellStyle} warp={warp} weft={weft} updatePattern={() => {}} />
<Weft cellStyle={cellStyle} warp={warp} weft={weft} baseSize={baseSize} updatePattern={() => {}} />
<Tieups cellStyle={cellStyle} warp={warp} weft={weft} tieups={tieups} baseSize={baseSize} updatePattern={() => {}} />
</div>
);
}
}
const mapDispatchToProps = dispatch => ({
onEditorUpdated: editor => dispatch(actions.objects.updateEditor(editor)),
onEditObject: (id, field, value) => dispatch(actions.objects.update(id, field, value)),
});
const DraftExportContainer = connect(
null, mapDispatchToProps,
)(DraftExport);
export default DraftExportContainer;

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react'; import React, { Component } from 'react';
import { useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import { Loader } from 'semantic-ui-react'; import { Loader } from 'semantic-ui-react';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
@ -11,40 +11,40 @@ import Weft from './Weft.js';
import Tieups from './Tieups.js'; import Tieups from './Tieups.js';
import Drawdown from './Drawdown.js'; import Drawdown from './Drawdown.js';
function DraftPreview({ object }) { class DraftPreview extends Component {
const [loading, setLoading] = useState(false); constructor(props) {
const [pattern, setPattern] = useState(); super(props);
const dispatch = useDispatch(); this.state = { loading: false };
const objectId = object?._id; }
const generatePreview = useCallback(() => { componentDidMount() {
setTimeout(() => { this.props.onEditorUpdated({ tool: 'pan' });
const c = document.getElementsByClassName('drawdown')[0]; this.setState({ loading: true });
const preview = c?.toDataURL(); api.objects.get(this.props.object._id, (object) => {
if (preview) { this.setState({ loading: false });
api.objects.update(objectId, { preview }, () => { if (object.pattern && object.pattern.warp) {
dispatch(actions.objects.update(objectId, 'preview', preview)); this.setState(object, () => {
if (this.props.onImageLoaded) this.unifyCanvas();
}); });
} }
}, 1000); if (!object.preview) {
}, [dispatch, objectId]); setTimeout(() => {
const c = document.getElementsByClassName('drawdown')[0];
useEffect(() => { const preview = c && c.toDataURL();
dispatch(actions.objects.updateEditor({ tool: 'pan' })); if (preview) {
setLoading(true); api.objects.update(object._id, { preview }, () => {
api.objects.get(objectId, (o) => { this.props.onEditObject(object._id, 'preview', preview);
setLoading(false); });
if (o.pattern && o.pattern.warp) { }
setPattern(o.pattern); }, 1000);
if (!o.preview) generatePreview();
} }
}, err => setLoading(false)); }, err => this.setState({ loading: false }));
}, [dispatch, objectId, generatePreview]); }
const unifyCanvas = useCallback(() => { unifyCanvas() {
if (!pattern) return;
const { warp, weft } = pattern;
setTimeout(() => { setTimeout(() => {
const id = this.props.object._id;
const { warp, weft } = this.state.pattern;
const baseSize = 6; const baseSize = 6;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@ -53,12 +53,12 @@ function DraftPreview({ object }) {
ctx.fillStyle = 'white'; ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'black'; ctx.fillStyle = 'black';
const warpCanvas = document.querySelector(`.preview-${objectId} .warp-threads`); const warpCanvas = document.querySelector(`.preview-${id} .warp-threads`);
const warpColourwayCanvas = document.querySelector(`.preview-${objectId} .warp-colourway`); const warpColourwayCanvas = document.querySelector(`.preview-${id} .warp-colourway`);
const weftCanvas = document.querySelector(`.preview-${objectId} .weft-threads`); const weftCanvas = document.querySelector(`.preview-${id} .weft-threads`);
const weftColourwayCanvas = document.querySelector(`.preview-${objectId} .weft-colourway`); const weftColourwayCanvas = document.querySelector(`.preview-${id} .weft-colourway`);
const drawdownCanvas = document.querySelector(`.preview-${objectId} .drawdown`); const drawdownCanvas = document.querySelector(`.preview-${id} .drawdown`);
const tieupsCanvas = document.querySelector(`.preview-${objectId} .tieups`); const tieupsCanvas = document.querySelector(`.preview-${id} .tieups`);
if (warpCanvas) { if (warpCanvas) {
ctx.drawImage(warpColourwayCanvas, canvas.width - warpCanvas.width - weft.treadles * baseSize - 20, 0); ctx.drawImage(warpColourwayCanvas, canvas.width - warpCanvas.width - weft.treadles * baseSize - 20, 0);
ctx.drawImage(warpCanvas, canvas.width - warpCanvas.width - weft.treadles * baseSize - 20, 10); ctx.drawImage(warpCanvas, canvas.width - warpCanvas.width - weft.treadles * baseSize - 20, 10);
@ -66,40 +66,47 @@ function DraftPreview({ object }) {
ctx.drawImage(weftColourwayCanvas, canvas.width - 10, warp.shafts * baseSize + 20); ctx.drawImage(weftColourwayCanvas, canvas.width - 10, warp.shafts * baseSize + 20);
ctx.drawImage(tieupsCanvas, canvas.width - weft.treadles * baseSize - 10, 10); ctx.drawImage(tieupsCanvas, canvas.width - weft.treadles * baseSize - 10, 10);
ctx.drawImage(drawdownCanvas, canvas.width - drawdownCanvas.width - weft.treadles * baseSize - 20, warp.shafts * baseSize + 20); ctx.drawImage(drawdownCanvas, canvas.width - drawdownCanvas.width - weft.treadles * baseSize - 20, warp.shafts * baseSize + 20);
dispatch(actions.objects.update(objectId, 'patternImage', canvas.toDataURL()));
this.props.onImageLoaded(canvas.toDataURL());
} }
}, 500); }, 500);
}, [dispatch, objectId, pattern]); }
useEffect(() => { render() {
unifyCanvas(); if (this.state.loading) return <Loader active />;
}, [unifyCanvas]) if (!this.state.pattern) return null;
const { warp, weft, tieups } = this.state.pattern;
if (loading) return <Loader active />; if (!warp || !weft || !tieups) return null;
if (!pattern) return null; const baseSize = 6;
const { warp, weft, tieups } = pattern; const cellStyle = { width: `${baseSize || 10}px`, height: `${baseSize || 10}px` };
if (!warp || !weft || !tieups) return null; return (
const baseSize = 6; <ElementPan
const cellStyle = { width: `${baseSize || 10}px`, height: `${baseSize || 10}px` }; startX={5000}
return ( startY={0}
<ElementPan
startX={5000}
startY={0}
>
<StyledPattern
className={`pattern preview-${objectId}`}
style={{
width: '2000px',
height: '1000px',
}}
> >
<Warp baseSize={baseSize} cellStyle={cellStyle} warp={warp} weft={weft} updatePattern={() => {}} /> <StyledPattern
<Weft cellStyle={cellStyle} warp={warp} weft={weft} baseSize={baseSize} updatePattern={() => {}} /> className={`pattern preview-${this.props.object._id}`}
<Tieups cellStyle={cellStyle} warp={warp} weft={weft} tieups={tieups} updatePattern={() => {}} baseSize={baseSize} /> style={{
<Drawdown warp={warp} weft={weft} tieups={tieups} baseSize={baseSize} /> width: '2000px',
</StyledPattern> height: '1000px',
</ElementPan> }}
); >
<Warp baseSize={baseSize} cellStyle={cellStyle} warp={warp} weft={weft} updatePattern={() => {}} />
<Weft cellStyle={cellStyle} warp={warp} weft={weft} baseSize={baseSize} updatePattern={() => {}} />
<Tieups cellStyle={cellStyle} warp={warp} weft={weft} tieups={tieups} updatePattern={() => {}} baseSize={baseSize} />
<Drawdown warp={warp} weft={weft} tieups={tieups} baseSize={baseSize} />
</StyledPattern>
</ElementPan>
);
}
} }
export default DraftPreview; const mapDispatchToProps = dispatch => ({
onEditorUpdated: editor => dispatch(actions.objects.updateEditor(editor)),
onEditObject: (id, field, value) => dispatch(actions.objects.update(id, field, value)),
});
const DraftPreviewContainer = connect(
null, mapDispatchToProps,
)(DraftPreview);
export default DraftPreviewContainer;

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react'; import React, { Component } from 'react';
import { useSelector } from 'react-redux'; import { connect } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import utils from 'utils/utils'; import utils from 'utils/utils';
@ -12,17 +12,23 @@ const StyledDrawdown = styled.canvas`
width: ${props => props.warp.threads * props.baseSize}px; width: ${props => props.warp.threads * props.baseSize}px;
`; `;
// Cache class Drawdown extends Component {
const squares = {}; constructor(props) {
super(props);
this.squares = {};
}
function Drawdown({ baseSize, warp, weft, tieups }) { componentDidMount() {
const drawdownRef = useRef(); this.paintDrawdown();
useEffect(() => paintDrawdown()); }
const { editor } = useSelector(state => ({ editor: state.objects.editor }));
const getSquare = (thread, size, colour) => { componentDidUpdate(prevProps, prevState) {
const { view } = editor; this.paintDrawdown(prevProps);
if (squares[view] && squares[view][thread] && squares[view][thread][size] && squares[view][thread][size][colour]) return squares[view][thread][size][colour]; }
getSquare(thread, size, colour) {
const { view } = this.props.editor;
if (this.squares[view] && this.squares[view][thread] && this.squares[view][thread][size] && this.squares[view][thread][size][colour]) return this.squares[view][thread][size][colour];
const m_canvas = document.createElement('canvas'); const m_canvas = document.createElement('canvas');
m_canvas.width = size; m_canvas.width = size;
m_canvas.height = size; m_canvas.height = size;
@ -37,7 +43,7 @@ function Drawdown({ baseSize, warp, weft, tieups }) {
if (view === 'colour' || view === 'interlacement') { if (view === 'colour' || view === 'interlacement') {
mc.fillStyle = colour; mc.fillStyle = colour;
mc.fillRect(0, 0, size, size); mc.fillRect(0, 0, size, size);
if (editor.view === 'interlacement') { if (this.props.editor.view === 'interlacement') {
if (thread === 'warp') { if (thread === 'warp') {
const grd = mc.createLinearGradient(0, 0, size, 0); const grd = mc.createLinearGradient(0, 0, size, 0);
grd.addColorStop(0.1, 'rgba(0,0,0,0.3)'); grd.addColorStop(0.1, 'rgba(0,0,0,0.3)');
@ -57,16 +63,19 @@ function Drawdown({ baseSize, warp, weft, tieups }) {
} }
} }
if (!squares[view]) squares[view] = {}; if (!this.squares[view]) this.squares[view] = {};
if (!squares[view][thread]) squares[view][thread] = {}; if (!this.squares[view][thread]) this.squares[view][thread] = {};
if (!squares[view][thread][size]) squares[view][thread][size] = {}; if (!this.squares[view][thread][size]) this.squares[view][thread][size] = {};
squares[view][thread][size][colour] = m_canvas; this.squares[view][thread][size][colour] = m_canvas;
return m_canvas; return m_canvas;
}; }
const paintDrawdown = () => { paintDrawdown(prevProps) {
const canvas = drawdownRef.current; const canvas = this.refs.drawdown;
const ctx = canvas.getContext('2d', { alpha: false }); const ctx = canvas.getContext('2d', { alpha: false });
const {
baseSize, warp, weft, tieups,
} = this.props;
for (let tread = 0; tread < weft.threads; tread++) { for (let tread = 0; tread < weft.threads; tread++) {
for (let thread = 0; thread < warp.threads; thread++) { for (let thread = 0; thread < warp.threads; thread++) {
@ -74,25 +83,40 @@ function Drawdown({ baseSize, warp, weft, tieups }) {
const shaft = warp.threading[thread].shaft; const shaft = warp.threading[thread].shaft;
const tieup = tieups[treadle - 1]; const tieup = tieups[treadle - 1];
const proceed = true; const proceed = true;
/* if (prevProps) {
const prevTreadle = prevProps.weft.treadling[tread].treadle;
const prevShaft = prevProps.warp.threading[thread].shaft;
const prevTieup = prevProps.tieups[prevTreadle - 1];
const prevBaseSize = prevProps.baseSize;
proceed = prevTreadle !== treadle || prevShaft !== shaft || baseSize !== prevBaseSize || prevTieup !== tieup;
} */
if (proceed) { if (proceed) {
const weftColour = utils.rgb(weft.treadling[tread].colour || weft.defaultColour); const weftColour = utils.rgb(weft.treadling[tread].colour || weft.defaultColour);
const warpColour = utils.rgb(warp.threading[thread].colour || warp.defaultColour); const warpColour = utils.rgb(warp.threading[thread].colour || warp.defaultColour);
const threadType = tieup && tieup.filter(t => t <= warp.shafts).indexOf(shaft) > -1 ? 'warp' : 'weft'; const threadType = tieup && tieup.filter(t => t <= warp.shafts).indexOf(shaft) > -1 ? 'warp' : 'weft';
const square = getSquare(threadType, baseSize, threadType === 'warp' ? warpColour : weftColour); const square = this.getSquare(threadType, baseSize, threadType === 'warp' ? warpColour : weftColour);
ctx.drawImage(square, canvas.width - (baseSize * (thread + 1)), tread * baseSize); ctx.drawImage(square, canvas.width - (baseSize * (thread + 1)), tread * baseSize);
} }
} }
} }
}; }
return ( render() {
<StyledDrawdown ref={drawdownRef} className="drawdown joyride-drawdown" const { warp, weft, baseSize } = this.props;
width={warp.threads * baseSize} return (
height={weft.threads * baseSize} <StyledDrawdown ref="drawdown" className="drawdown joyride-drawdown"
weft={weft} warp={warp} baseSize={baseSize} width={warp.threads * baseSize}
/> height={weft.threads * baseSize}
); weft={weft} warp={warp} baseSize={baseSize}
/>
);
}
} }
export default Drawdown; const mapStateToProps = (state, ownProps) => ({ editor: state.objects.editor });
const DrawdownContainer = connect(
mapStateToProps,
)(Drawdown);
export default DrawdownContainer;

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react'; import React, { Component } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
const StyledTieups = styled.canvas` const StyledTieups = styled.canvas`
@ -7,40 +7,46 @@ const StyledTieups = styled.canvas`
right:10px; right:10px;
`; `;
function Tieups({ cellStyle, warp, weft, tieups, updatePattern, baseSize }) { class Tieups extends Component {
useEffect(() => paintTieups());
const tieupRef = useRef(null);
const fillUpTo = (t, limit) => { componentDidUpdate(prevProps, prevState) {
let i = t.length; this.paintTieups();
}
componentDidMount() {
this.paintTieups();
}
fillUpTo = (tieups, limit) => {
let i = tieups.length;
while (i <= limit) { while (i <= limit) {
t.push([]); tieups.push([]);
i++; i++;
} }
}; }
const getTieupShaft = (event) => { getTieupShaft = (event) => {
const rect = event.currentTarget.getBoundingClientRect(); const rect = event.currentTarget.getBoundingClientRect();
const y = event.clientY - rect.top; const y = event.clientY - rect.top;
const x = 0 - (event.clientX - rect.right); const x = 0 - (event.clientX - rect.right);
const shaft = warp.shafts - parseInt(y / baseSize); const shaft = this.props.warp.shafts - parseInt(y / this.props.baseSize);
const tieup = weft.treadles - parseInt(x / baseSize) - 1; const tieup = this.props.weft.treadles - parseInt(x / this.props.baseSize) - 1;
return { tieup, shaft }; return { tieup, shaft };
}; }
const click = (event) => { click = (event) => {
const { tieup, shaft } = getTieupShaft(event); const { tieup, shaft } = this.getTieupShaft(event);
const newTieups = Object.assign([], tieups); const tieups = Object.assign([], this.props.tieups);
if (tieup >= tieups.length) fillUpTo(newTieups, tieup); if (tieup >= tieups.length) this.fillUpTo(tieups, tieup);
if (tieups[tieup] !== undefined) { if (tieups[tieup] !== undefined) {
if (tieups[tieup].indexOf(shaft) === -1) newTieups[tieup].push(shaft); if (tieups[tieup].indexOf(shaft) === -1) tieups[tieup].push(shaft);
else newTieups[tieup].splice(tieups[tieup].indexOf(shaft)); else tieups[tieup].splice(tieups[tieup].indexOf(shaft));
} }
updatePattern({ tieups: newTieups }); this.props.updatePattern({ tieups });
}; }
const paintTieups = () => { paintTieups() {
const canvas = tieupRef.current; const canvas = this.refs.tieups;
const ctx = canvas.getContext('2d');// , { alpha: false }); const ctx = canvas.getContext('2d');// , { alpha: false });
const { baseSize, tieups } = this.props;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
@ -62,9 +68,12 @@ function Tieups({ cellStyle, warp, weft, tieups, updatePattern, baseSize }) {
} }
} }
return ( render() {
<StyledTieups ref={tieupRef} className='tieups joyride-tieups' width={weft.treadles * baseSize} height= {warp.shafts * baseSize} style={{width: weft.treadles * baseSize, height: warp.shafts * baseSize}} onClick={click}/> const { warp, weft, baseSize } = this.props;
); return (
<StyledTieups ref='tieups' className='tieups joyride-tieups' width={weft.treadles * baseSize} height= {warp.shafts * baseSize} style={{width: weft.treadles * baseSize, height: warp.shafts * baseSize}} onClick={this.click}/>
);
}
} }
export default Tieups; export default Tieups;

View File

@ -1,9 +1,9 @@
import React, { useState } from 'react'; import React, { Component } from 'react';
import { import {
Confirm, Select, Segment, Accordion, Grid, Icon, Input, Button, Confirm, Select, Segment, Accordion, Grid, Icon, Input, Button,
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { useSelector, useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import Slider from 'rc-slider'; import Slider from 'rc-slider';
import styled from 'styled-components'; import styled from 'styled-components';
@ -35,78 +35,69 @@ const ColourSquare = styled.div`
} }
`; `;
function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updatePattern, updateObject, saveObject }) { class Tools extends Component {
const [activeDrawers, setActiveDrawers] = useState(['properties', 'drawing', 'palette']); state = { colours: [], activeDrawers: ['properties', 'drawing', 'palette'], view: 'interlacement' }
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const navigate = useNavigate();
const dispatch = useDispatch();
const { objectId, username, projectPath } = useParams();
const { project, editor } = useSelector(state => { enableTool = (tool) => {
let project = {}; this.props.onEditorUpdated({ tool, colour: this.props.editor.colour });
state.projects.projects.forEach((p) => { }
if (p.path === projectPath && p.owner && p.owner.username === username) project = p;
});
return { project, editor: state.objects.editor };
});
const enableTool = (tool) => { setColour = (colour) => {
dispatch(actions.objects.updateEditor({ tool, colour: editor.colour })); this.props.onEditorUpdated({ tool: 'colour', colour });
}; }
const setColour = (colour) => { setView = (view) => {
dispatch(actions.objects.updateEditor({ tool: 'colour', colour })); this.props.onEditorUpdated({ view });
}; }
const setEditorView = (view) => { setName = (event) => {
dispatch(actions.objects.updateEditor({ view })); this.props.updateObject({ name: event.target.value });
}; }
const setName = (event) => { setShafts = (event) => {
updateObject({ name: event.target.value }); const warp = { ...this.props.warp, shafts: parseInt(event.target.value, 10) || 1 };
}; this.props.updatePattern({ warp });
}
const setShafts = (event) => { setTreadles = (event) => {
updatePattern({ warp: { ...warp, shafts: parseInt(event.target.value, 10) || 1 } }); const weft = { ...this.props.weft, treadles: parseInt(event.target.value, 10) || 1 };
}; this.props.updatePattern({ weft });
}
const setTreadles = (event) => { onZoomChange = zoom => this.props.updatePattern({ baseSize: zoom || 10 })
updatePattern({ weft: { ...weft, treadles: parseInt(event.target.value, 10) || 1 } });
};
const onZoomChange = zoom => updatePattern({ baseSize: zoom || 10 }); drawerIsActive = drawer => this.state.activeDrawers.indexOf(drawer) > -1
const drawerIsActive = drawer => activeDrawers.indexOf(drawer) > -1; activateDrawer = (drawer) => {
const index = this.state.activeDrawers.indexOf(drawer);
const activateDrawer = (drawer) => { const drawers = this.state.activeDrawers;
const index = activeDrawers.indexOf(drawer);
const drawers = activeDrawers;
if (index === -1) { if (index === -1) {
drawers.push(drawer); drawers.push(drawer);
} else { } else {
drawers.splice(index, 1); drawers.splice(index, 1);
} }
setActiveDrawers(drawers); this.setState({ activeDrawers: drawers });
}; }
const deleteObject = () => { deleteObject = () => {
api.objects.delete(objectId, () => { api.objects.delete(this.props.match.params.objectId, () => {
toast('🗑️ Pattern deleted'); toast('🗑️ Pattern deleted');
dispatch(actions.objects.delete(objectId)); this.props.onObjectDeleted(this.props.match.params.objectId);
navigate(`/${project.fullName}`); this.props.history.push(`/${this.props.project.fullName}`);
}, err => console.log(err)); }, err => console.log(err));
} }
const revertChanges = () => { revertChanges = () => {
const sure = window.confirm('Really revert your changes to your last save point?\n\nAny updates to your pattern since you last saved will be lost.') const sure = window.confirm('Really revert your changes to your last save point?\n\nAny updates to your pattern since you last saved will be lost.')
if (sure) { if (sure) {
window.location.reload(); window.location.reload();
} }
}; }
const changeWidth = () => { changeWidth = () => {
const newWidth = parseInt(window.prompt('Enter a new width for your pattern.\n\nIMPORTANT: If your new width is less than the current width, then any shafts selected beyond the new width will be lost.')); const newWidth = parseInt(window.prompt('Enter a new width for your pattern.\n\nIMPORTANT: If your new width is less than the current width, then any shafts selected beyond the new width will be lost.'));
if (!newWidth) return; if (!newWidth) return;
const { warp } = this.props;
if (newWidth > warp.threading.length) { if (newWidth > warp.threading.length) {
let i = warp.threading.length; let i = warp.threading.length;
while (i < newWidth) { while (i < newWidth) {
@ -119,13 +110,14 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
warp.threading.splice(newWidth); warp.threading.splice(newWidth);
warp.threads = warp.threading.length; warp.threads = warp.threading.length;
} }
updatePattern({ warp }); this.props.updatePattern({ warp });
dispatch(actions.objects.updateEditor()); this.props.onEditorUpdated();
dispatch(actions.objects.updateEditor( { tool: 'pan' })); this.props.onEditorUpdated({ tool: 'pan' });
}; }
const changeHeight = () => { changeHeight = () => {
const newHeight = parseInt(window.prompt('Enter a new height for your pattern.\n\nIMPORTANT: If your new height is less than the current height, then any treadles selected beyond the new height will be lost.')); const newHeight = parseInt(window.prompt('Enter a new height for your pattern.\n\nIMPORTANT: If your new height is less than the current height, then any treadles selected beyond the new height will be lost.'));
if (!newHeight) return; if (!newHeight) return;
const { weft } = this.props;
if (newHeight > weft.treadling.length) { if (newHeight > weft.treadling.length) {
let i = weft.treadling.length; let i = weft.treadling.length;
while (i < newHeight) { while (i < newHeight) {
@ -138,115 +130,134 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
weft.treadling.splice(newHeight); weft.treadling.splice(newHeight);
weft.threads = weft.treadling.length; weft.threads = weft.treadling.length;
} }
updatePattern({ weft }); this.props.updatePattern({ weft });
dispatch(actions.objects.updateEditor()); this.props.onEditorUpdated();
}; }
return ( render() {
<div className="pattern-toolbox joyride-tools"> const { warp, weft, editor, unsaved, saving } = this.props;
{unsaved && return (
<Segment attached="top"> <div className="pattern-toolbox joyride-tools">
<Button fluid color="teal" icon="save" content="Save pattern" onClick={() => saveObject(/*this.refs.canvas*/)} loading={saving}/> {unsaved &&
<Button style={{marginTop: 5}} fluid icon='refresh' content='Undo changes' onClick={revertChanges} /> <Segment attached="top">
<Button fluid color="teal" icon="save" content="Save pattern" onClick={() => this.props.saveObject(this.refs.canvas)} loading={saving}/>
<Button style={{marginTop: 5}} fluid icon='refresh' content='Undo changes' onClick={this.revertChanges} />
</Segment>
}
<Segment attached>
<Accordion fluid>
<Accordion.Title active={this.drawerIsActive('view')} onClick={e => this.activateDrawer('view')}>
<Icon name="dropdown" /> View
</Accordion.Title>
<Accordion.Content active={this.drawerIsActive('view')}>
<small>Drawdown view</small>
<Select
size="tiny"
fluid
value={editor.view}
onChange={(e, s) => this.setView(s.value)}
style={{ fontSize: '11px' }}
options={[
{ key: 1, value: 'interlacement', text: 'Interlacement' },
{ key: 2, value: 'colour', text: 'Colour only' },
{ key: 3, value: 'warp', text: 'Warp view' },
{ key: 4, value: 'weft', text: 'Weft view' },
]}
/>
<div style={{ marginTop: '5px' }} />
<small>Zoom</small>
<Slider defaultValue={this.props.baseSize} min={5} max={13} step={1} onAfterChange={this.onZoomChange} />
</Accordion.Content>
<Accordion.Title active={this.drawerIsActive('properties')} onClick={e => this.activateDrawer('properties')}>
<Icon name="dropdown" /> Properties
</Accordion.Title>
<Accordion.Content active={this.drawerIsActive('properties')}>
<small>Name</small>
<Input type="text" size="small" fluid style={{ marginBottom: '5px' }} value={this.props.object.name} onChange={this.setName} />
<Grid columns={2}>
<Grid.Row className='joyride-threads'>
<Grid.Column>
<small>Shafts</small>
<Input fluid type="number" value={warp.shafts} onKeyDown={e => false} onChange={this.setShafts} size="mini" />
</Grid.Column>
<Grid.Column>
<small>Treadles</small>
<Input fluid type="number" value={weft.treadles} onKeyDown={e => false} onChange={this.setTreadles} size="mini" />
</Grid.Column>
</Grid.Row>
<Grid.Row style={{paddingTop: 0}}>
<Grid.Column>
<small>Width</small>
<Input fluid readOnly value={warp.threading?.length || 0} size="mini"
action={{icon: 'edit', onClick: this.changeWidth}}
/>
</Grid.Column>
<Grid.Column>
<small>Height</small>
<Input fluid readOnly value={weft.treadling?.length || 0} size="mini"
action={{icon: 'edit', onClick: this.changeHeight}}
/>
</Grid.Column>
</Grid.Row>
</Grid>
</Accordion.Content>
<Accordion.Title active={this.drawerIsActive('drawing')} onClick={e => this.activateDrawer('drawing')}>
<Icon name="dropdown" /> Tools
</Accordion.Title>
<Accordion.Content active={this.drawerIsActive('drawing')}>
<Button.Group fluid>
<Button className='joyride-pan' data-tooltip="Pan (drag to move) pattern" color={this.props.editor.tool === 'pan' && 'blue'} size="tiny" icon onClick={() => this.enableTool('pan')}><Icon name="move" /></Button>
<Button className='joyride-colour' data-tooltip="Paint selected colour" color={this.props.editor.tool === 'colour' && 'blue'} size="tiny" icon onClick={() => this.enableTool('colour')}><Icon name="paint brush" /></Button>
<Button className='joyride-straight' data-tooltip="Straight draw" color={this.props.editor.tool === 'straight' && 'blue'} size="tiny" icon onClick={() => this.enableTool('straight')}>/ /</Button>
<Button className='joyride-point' data-tooltip="Point draw" color={this.props.editor.tool === 'point' && 'blue'} size="tiny" icon onClick={() => this.enableTool('point')}><Icon name="chevron up" /></Button>
</Button.Group>
</Accordion.Content>
<Accordion.Title active={this.drawerIsActive('palette')} onClick={e => this.activateDrawer('palette')}>
<Icon name="dropdown" /> Palette
<ColourSquare colour={utils.rgb(this.props.editor.colour)} style={{top: 4, marginLeft: 10}}/>
</Accordion.Title>
<Accordion.Content active={this.drawerIsActive('palette')}>
{this.props.pattern.colours && this.props.pattern.colours.map(colour =>
<ColourSquare key={colour} colour={utils.rgb(colour)} onClick={() => this.setColour(colour)} />
)}
</Accordion.Content>
<Accordion.Title active={this.drawerIsActive('advanced')} onClick={e => this.activateDrawer('advanced')}>
<Icon name="dropdown" /> Advanced
</Accordion.Title>
<Accordion.Content active={this.drawerIsActive('advanced')}>
<Button size="small" basic color="red" fluid onClick={e => this.setState({ deleteModalOpen: true })}>Delete pattern</Button>
<Confirm
open={this.state.deleteModalOpen}
content="Really delete this pattern?"
onCancel={e => this.setState({ deleteModalOpen: false })}
onConfirm={this.deleteObject}
/>
</Accordion.Content>
</Accordion>
</Segment> </Segment>
} </div>
<Segment attached> );
<Accordion fluid> }
<Accordion.Title active={drawerIsActive('view')} onClick={e => activateDrawer('view')}>
<Icon name="dropdown" /> View
</Accordion.Title>
<Accordion.Content active={drawerIsActive('view')}>
<small>Drawdown view</small>
<Select
size="tiny"
fluid
value={editor.view}
onChange={(e, s) => setEditorView(s.value)}
style={{ fontSize: '11px' }}
options={[
{ key: 1, value: 'interlacement', text: 'Interlacement' },
{ key: 2, value: 'colour', text: 'Colour only' },
{ key: 3, value: 'warp', text: 'Warp view' },
{ key: 4, value: 'weft', text: 'Weft view' },
]}
/>
<div style={{ marginTop: '5px' }} />
<small>Zoom</small>
<Slider defaultValue={baseSize} min={5} max={13} step={1} onAfterChange={onZoomChange} />
</Accordion.Content>
<Accordion.Title active={drawerIsActive('properties')} onClick={e => activateDrawer('properties')}>
<Icon name="dropdown" /> Properties
</Accordion.Title>
<Accordion.Content active={drawerIsActive('properties')}>
<small>Name</small>
<Input type="text" size="small" fluid style={{ marginBottom: '5px' }} value={object.name} onChange={setName} />
<Grid columns={2}>
<Grid.Row className='joyride-threads'>
<Grid.Column>
<small>Shafts</small>
<Input fluid type="number" value={warp.shafts} onKeyDown={e => false} onChange={setShafts} size="mini" />
</Grid.Column>
<Grid.Column>
<small>Treadles</small>
<Input fluid type="number" value={weft.treadles} onKeyDown={e => false} onChange={setTreadles} size="mini" />
</Grid.Column>
</Grid.Row>
<Grid.Row style={{paddingTop: 0}}>
<Grid.Column>
<small>Width</small>
<Input fluid readOnly value={warp.threading?.length || 0} size="mini"
action={{icon: 'edit', onClick: changeWidth}}
/>
</Grid.Column>
<Grid.Column>
<small>Height</small>
<Input fluid readOnly value={weft.treadling?.length || 0} size="mini"
action={{icon: 'edit', onClick: changeHeight}}
/>
</Grid.Column>
</Grid.Row>
</Grid>
</Accordion.Content>
<Accordion.Title active={drawerIsActive('drawing')} onClick={e => activateDrawer('drawing')}>
<Icon name="dropdown" /> Tools
</Accordion.Title>
<Accordion.Content active={drawerIsActive('drawing')}>
<Button.Group fluid>
<Button className='joyride-pan' data-tooltip="Pan (drag to move) pattern" color={editor.tool === 'pan' && 'blue'} size="tiny" icon onClick={() => enableTool('pan')}><Icon name="move" /></Button>
<Button className='joyride-colour' data-tooltip="Paint selected colour" color={editor.tool === 'colour' && 'blue'} size="tiny" icon onClick={() => enableTool('colour')}><Icon name="paint brush" /></Button>
<Button className='joyride-straight' data-tooltip="Straight draw" color={editor.tool === 'straight' && 'blue'} size="tiny" icon onClick={() => enableTool('straight')}>/ /</Button>
<Button className='joyride-point' data-tooltip="Point draw" color={editor.tool === 'point' && 'blue'} size="tiny" icon onClick={() => enableTool('point')}><Icon name="chevron up" /></Button>
</Button.Group>
</Accordion.Content>
<Accordion.Title active={drawerIsActive('palette')} onClick={e => activateDrawer('palette')}>
<Icon name="dropdown" /> Palette
<ColourSquare colour={utils.rgb(editor.colour)} style={{top: 4, marginLeft: 10}}/>
</Accordion.Title>
<Accordion.Content active={drawerIsActive('palette')}>
{pattern.colours && pattern.colours.map(colour =>
<ColourSquare key={colour} colour={utils.rgb(colour)} onClick={() => setColour(colour)} />
)}
</Accordion.Content>
<Accordion.Title active={drawerIsActive('advanced')} onClick={e => activateDrawer('advanced')}>
<Icon name="dropdown" /> Advanced
</Accordion.Title>
<Accordion.Content active={drawerIsActive('advanced')}>
<Button size="small" basic color="red" fluid onClick={e => setDeleteModalOpen(true)}>Delete pattern</Button>
<Confirm
open={deleteModalOpen}
content="Really delete this pattern?"
onCancel={e => setDeleteModalOpen(false)}
onConfirm={deleteObject}
/>
</Accordion.Content>
</Accordion>
</Segment>
</div>
);
} }
export default Tools; const mapStateToProps = (state, ownProps) => {
const { username, projectPath } = ownProps.match.params;
let project = {};
state.projects.projects.forEach((p) => {
if (p.path === projectPath && p.owner && p.owner.username === username) project = p;
});
return { project, fullName: `${username}/${projectPath}`, editor: state.objects.editor };
};
const mapDispatchToProps = dispatch => ({
onEditorUpdated: editor => dispatch(actions.objects.updateEditor(editor)),
onObjectDeleted: id => dispatch(actions.objects.delete(id)),
});
const ToolsContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(Tools));
export default ToolsContainer;

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { Component } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useSelector } from 'react-redux'; import { connect } from 'react-redux';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
@ -17,154 +17,157 @@ const StyledWarp = styled.div`
} }
`; `;
const squares = {}; class Warp extends Component {
const markers = {}; constructor(props) {
super(props);
this.squares = {};
this.markers = {};
}
componentDidUpdate(prevProps, prevState) {
this.paintDrawdown();
}
componentDidMount() {
this.paintDrawdown();
}
function Warp({ baseSize, cellStyle, warp, weft, updatePattern }) { getThreadShaft = (event) => {
const [draggingColourway, setDraggingColourway] = useState(false);
const [dragging, setDragging] = useState(false);
const [startShaft, setStartShaft] = useState();
const [startThread, setStartThread] = useState();
const { editor } = useSelector(state => ({ editor: state.objects.editor }));
useEffect(() => paintDrawdown());
const warpRef = useRef(null);
const colourwayRef = useRef(null);
const getThreadShaft = (event) => {
const rect = event.currentTarget.getBoundingClientRect(); const rect = event.currentTarget.getBoundingClientRect();
const y = event.clientY - rect.top; const y = event.clientY - rect.top;
const x = 0 - (event.clientX - rect.right); const x = 0 - (event.clientX - rect.right);
const shaft = warp.shafts - parseInt(y / baseSize); const shaft = this.props.warp.shafts - parseInt(y / this.props.baseSize);
const thread = parseInt(x / baseSize); const thread = parseInt(x / this.props.baseSize);
return { shaft, thread }; return { shaft, thread };
}; }
const mouseClickColourway = event => { mouseClickColourway = event => {
const newWarp = Object.assign({}, warp); const warp = Object.assign({}, this.props.warp);
const { thread } = getThreadShaft(event); const { thread } = this.getThreadShaft(event);
if (thread >= warp.threading.length) fillUpTo(newWarp, thread); if (thread >= warp.threading.length) this.fillUpTo(warp, thread);
newWarp.threading[thread].colour = editor.colour; warp.threading[thread].colour = this.props.colour;
updatePattern({ warp: newWarp }); this.props.updatePattern({ warp });
}; }
const mouseDownColourway = event => { mouseDownColourway = event => {
event.preventDefault(); event.preventDefault();
setDraggingColourway(true); this.draggingColourway = true;
}; }
const mouseUpColourway = event => setDraggingColourway(false); mouseUpColourway = event => this.draggingColourway = false;
const mouseMoveColourway = (event) => { mouseMoveColourway = (event) => {
if (draggingColourway) { if (this.draggingColourway) {
const newWarp = Object.assign({}, warp); const warp = Object.assign({}, this.props.warp);
const { thread } = getThreadShaft(event); const { thread } = this.getThreadShaft(event);
if (thread >= warp.threading.length) fillUpTo(newWarp, thread); if (thread >= warp.threading.length) this.fillUpTo(warp, thread);
newWarp.threading[thread].colour = editor.colour; warp.threading[thread].colour = this.props.colour;
updatePattern({ warp: newWarp }); this.props.updatePattern({ warp });
} }
}; }
const mouseUp = event => setDragging(false); mouseUp = event => this.dragging = false;
const mouseDown = (event) => { mouseDown = (event) => {
event.preventDefault(); event.preventDefault();
const { shaft, thread } = getThreadShaft(event); const { shaft, thread } = this.getThreadShaft(event);
setStartShaft(shaft); this.startShaft = shaft;
setStartThread(thread); this.startThread = thread;
setDragging(true); this.dragging = true;
}; }
const mouseMove = (event) => { mouseMove = (event) => {
if (dragging && editor.tool) { if (this.dragging && this.props.tool) {
const newWarp = Object.assign({}, warp); const warp = Object.assign({}, this.props.warp);
const { shaft, thread } = getThreadShaft(event); const { shaft, thread } = this.getThreadShaft(event);
let lX = startThread; let hX = thread; let lY = startShaft; let hY = shaft; let lX = this.startThread; let hX = thread; let lY = this.startShaft; let
let xDirection = 1; let yDirection = 1; hY = shaft;
if (thread < startThread) { let xDirection = 1; let
yDirection = 1;
if (thread < this.startThread) {
lX = thread; lX = thread;
hX = startThread; hX = this.startThread;
xDirection = -1; xDirection = -1;
} }
if (shaft < startShaft) { if (shaft < this.startShaft) {
lY = shaft; lY = shaft;
hY = startShaft; hY = this.startShaft;
yDirection = -1; yDirection = -1;
} }
let x = xDirection > 0 ? lX : hX; let x = xDirection > 0 ? lX : hX;
let y = yDirection > 0 ? lY : hY; let y = yDirection > 0 ? lY : hY;
if (editor.tool === 'colour') { if (this.props.tool === 'colour') {
if (thread >= warp.threading.length) fillUpTo(newWarp, thread); if (thread >= warp.threading.length) this.fillUpTo(warp, thread);
newWarp.threading[thread].colour = editor.colour; warp.threading[thread].colour = this.props.colour;
} }
if (editor.tool === 'straight') { if (this.props.tool === 'straight') {
while (x <= hX && x >= lX) { while (x <= hX && x >= lX) {
if (x >= warp.threading.length || warp.threading.length - x < 5) fillUpTo(newWarp, x + 5); if (x >= warp.threading.length || warp.threading.length - x < 5) this.fillUpTo(warp, x + 5);
newWarp.threading[x].shaft = y; warp.threading[x].shaft = y;
x += xDirection; x += xDirection;
y += yDirection; y += yDirection;
if (y > hY || y < lY) y = yDirection > 0 ? lY : hY; if (y > hY || y < lY) y = yDirection > 0 ? lY : hY;
} }
} }
if (editor.tool === 'point') { if (this.props.tool === 'point') {
while (x <= hX && x >= lX) { while (x <= hX && x >= lX) {
if (x >= warp.threading.length || warp.threading.length - x < 5) fillUpTo(newWarp, x + 5); if (x >= warp.threading.length || warp.threading.length - x < 5) this.fillUpTo(warp, x + 5);
newWarp.threading[x].shaft = y; warp.threading[x].shaft = y;
x += xDirection; x += xDirection;
y += yDirection; y += yDirection;
if (y > hY || y <= lY) yDirection = 0 - yDirection; if (y > hY || y <= lY) yDirection = 0 - yDirection;
} }
} }
updatePattern({ warp: newWarp }); this.props.updatePattern({ warp });
} }
}; }
const click = (event) => { click = (event) => {
if (editor.tool === 'point' || editor.tool === 'straight') { if (this.props.tool === 'point' || this.props.tool === 'straight') {
const { thread, shaft } = getThreadShaft(event); const { thread, shaft } = this.getThreadShaft(event);
const newWarp = Object.assign({}, warp); const warp = Object.assign({}, this.props.warp);
if (thread > warp.threading.length || warp.threading.length - thread < 5) fillUpTo(newWarp, thread + 5); if (thread > warp.threading.length || warp.threading.length - thread < 5) this.fillUpTo(warp, thread + 5);
const warpThread = newWarp.threading[thread]; const warpThread = warp.threading[thread];
warpThread.shaft = warpThread.shaft === shaft ? 0 : shaft; warpThread.shaft = warpThread.shaft === shaft ? 0 : shaft;
updatePattern({ warp: newWarp }); this.props.updatePattern({ warp });
} }
}; }
const fillUpTo = (w, limit) => { fillUpTo = (warp, limit) => {
let i = warp.threading.length; let i = warp.threading.length;
while (i <= limit) { while (i <= limit) {
w.threading.push({ shaft: 0 }); warp.threading.push({ shaft: 0 });
w.threads++; warp.threads++;
i++; i++;
} }
}; }
const getMarker = (size) => {
if (markers[size]) return markers[size]; getMarker(size) {
if (this.markers[size]) return this.markers[size];
const m_canvas = document.createElement('canvas'); const m_canvas = document.createElement('canvas');
m_canvas.width = baseSize; m_canvas.width = this.props.baseSize;
m_canvas.height = baseSize; m_canvas.height = this.props.baseSize;
const mc = m_canvas.getContext('2d'); const mc = m_canvas.getContext('2d');
mc.fillStyle = 'black'; mc.fillStyle = 'black';
mc.fillRect(0, 0, baseSize, baseSize); mc.fillRect(0, 0, this.props.baseSize, this.props.baseSize);
markers[size] = m_canvas; this.markers[size] = m_canvas;
return m_canvas; return m_canvas;
}; }
const getSquare = (size, colour) => { getSquare(size, colour) {
if (squares[size] && squares[size][colour]) return squares[size][colour]; if (this.squares[size] && this.squares[size][colour]) return this.squares[size][colour];
const m_canvas = document.createElement('canvas'); const m_canvas = document.createElement('canvas');
m_canvas.width = baseSize; m_canvas.width = this.props.baseSize;
m_canvas.height = 10; m_canvas.height = 10;
const mc = m_canvas.getContext('2d'); const mc = m_canvas.getContext('2d');
mc.fillStyle = utils.rgb(colour); mc.fillStyle = utils.rgb(colour);
mc.fillRect(0, 0, baseSize, 10); mc.fillRect(0, 0, this.props.baseSize, 10);
if (!squares[size]) squares[size] = {}; if (!this.squares[size]) this.squares[size] = {};
squares[size][colour] = m_canvas; this.squares[size][colour] = m_canvas;
return m_canvas; return m_canvas;
}; }
const paintDrawdown = () => { paintDrawdown() {
const canvas = warpRef.current; const canvas = this.refs.warp;
const colourway = colourwayRef.current; const colourway = this.refs.colourway;
const ctx = canvas.getContext('2d');// , { alpha: false }); const ctx = canvas.getContext('2d');// , { alpha: false });
const ctx2 = colourway.getContext('2d');// , { alpha: false }); const ctx2 = colourway.getContext('2d');// , { alpha: false });
const { baseSize, warp } = this.props;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
@ -181,40 +184,52 @@ function Warp({ baseSize, cellStyle, warp, weft, updatePattern }) {
for (let thread = 0; thread < warp.threads; thread++) { for (let thread = 0; thread < warp.threads; thread++) {
const shaft = warp.threading[thread].shaft; const shaft = warp.threading[thread].shaft;
const marker = getMarker(baseSize); const marker = this.getMarker(baseSize);
ctx.drawImage(marker, canvas.width - ((thread + 1) * baseSize), canvas.height - (shaft * baseSize)); ctx.drawImage(marker, canvas.width - ((thread + 1) * this.props.baseSize), canvas.height - (shaft * this.props.baseSize));
const colourSquare = getSquare(baseSize, warp.threading[thread].colour || warp.defaultColour); const colourSquare = this.getSquare(baseSize, warp.threading[thread].colour || warp.defaultColour);
ctx2.drawImage(colourSquare, canvas.width - ((thread + 1) * baseSize), 0); ctx2.drawImage(colourSquare, canvas.width - ((thread + 1) * this.props.baseSize), 0);
} }
}; }
return (
<StyledWarp treadles={weft.treadles} shafts={warp.shafts} baseSize={baseSize}>
<canvas className='warp-colourway joyride-warpColourway' ref={colourwayRef} width={warp.threading.length * baseSize} height={10} render() {
style={{ const { warp, weft, baseSize } = this.props;
position: 'absolute', top: 0, right: 0, height: 10, width: warp.threading.length * baseSize, return (
}} <StyledWarp treadles={weft.treadles} shafts={warp.shafts} baseSize={baseSize}>
onClick={mouseClickColourway} <canvas className='warp-colourway joyride-warpColourway' ref="colourway" width={warp.threading.length * baseSize} height={10}
onMouseDown={mouseDownColourway} style={{
onMouseMove={mouseMoveColourway} position: 'absolute', top: 0, right: 0, height: 10, width: warp.threading.length * baseSize,
onMouseUp={mouseUpColourway} }}
onMouseLeave={mouseUpColourway} onClick={this.mouseClickColourway}
/> onMouseDown={this.mouseDownColourway}
<canvas className='warp-threads joyride-warp' ref={warpRef} width={warp.threading.length * baseSize} height={warp.shafts * baseSize} onMouseMove={this.mouseMoveColourway}
style={{ onMouseUp={this.mouseUpColourway}
position: 'absolute', top: 10, right: 0, onMouseLeave={this.mouseUpColourway}
height: warp.shafts * baseSize, />
width: warp.threading.length * baseSize, borderRadius: 4, <canvas className='warp-threads joyride-warp' ref="warp" width={warp.threading.length * baseSize} height={warp.shafts * baseSize}
boxShadow: '0px 0px 10px rgba(0,0,0,0.15)', style={{
}} position: 'absolute', top: 10, right: 0,
onClick={click} height: warp.shafts * baseSize,
onMouseDown={mouseDown} width: warp.threading.length * baseSize, borderRadius: 4,
onMouseMove={mouseMove} boxShadow: '0px 0px 10px rgba(0,0,0,0.15)',
onMouseUp={mouseUp} }}
onMouseLeave={mouseUp} onClick={this.click}
/> onMouseDown={this.mouseDown}
</StyledWarp> onMouseMove={this.mouseMove}
); onMouseUp={this.mouseUp}
onMouseLeave={this.mouseUp}
/>
</StyledWarp>
);
}
} }
export default Warp; const mapStateToProps = (state, ownProps) => state.objects.editor;
const mapDispatchToProps = dispatch => ({
});
const WarpContainer = connect(
mapStateToProps, mapDispatchToProps,
)(Warp);
export default WarpContainer;

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { Component } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useSelector } from 'react-redux'; import { connect } from 'react-redux';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
const StyledWeft = styled.div` const StyledWeft = styled.div`
@ -18,156 +18,172 @@ const StyledWeft = styled.div`
} }
`; `;
// Cache class Weft extends Component {
const squares = {}; constructor(props) {
const markers = {}; super(props);
this.squares = {};
this.markers = {};
}
componentDidUpdate(prevProps, prevState) {
this.paintDrawdown();
}
componentDidMount() {
this.paintDrawdown();
}
function Weft({ cellStyle, warp, weft, baseSize, updatePattern }) { toggleWeft = (treadle, threadCount) => {
const [draggingColourway, setDraggingColourway] = useState(false); const weft = Object.assign({}, this.props.weft);
const [dragging, setDragging] = useState(false); const thread = weft.treadling[threadCount];
const [startTreadle, setStartTreadle] = useState(); thread.treadle = thread.treadle === treadle ? 0 : treadle;
const [startThread, setStartThread] = useState(); this.props.updatePattern({ weft });
}
const { editor } = useSelector(state => ({ editor: state.objects.editor })); changeWeftColour = (threadIndex) => {
useEffect(() => paintDrawdown()); const weft = Object.assign({}, this.props.weft);
const weftRef = useRef(null); const colour = this.props.colour;
const colourwayRef = useRef(null); if (colour) {
weft.treadling[threadIndex].colour = colour;
this.props.updatePattern({ weft });
}
}
const getThreadTreadle = (event) => { getThreadTreadle = (event) => {
const rect = event.currentTarget.getBoundingClientRect(); const rect = event.currentTarget.getBoundingClientRect();
const y = event.clientY - rect.top; const y = event.clientY - rect.top;
const x = (event.clientX - rect.left); const x = (event.clientX - rect.left);
const thread = parseInt(y / baseSize) + 1; const thread = parseInt(y / this.props.baseSize) + 1;
const treadle = parseInt(x / baseSize); const treadle = parseInt(x / this.props.baseSize);
return { treadle, thread }; return { treadle, thread };
}; }
const mouseClickColourway = event => { mouseClickColourway = event => {
const newWeft = Object.assign({}, weft); const weft = Object.assign({}, this.props.weft);
const { thread } = getThreadTreadle(event); const { thread } = this.getThreadTreadle(event);
if (thread >= weft.treadling.length) fillUpTo(newWeft, thread); if (thread >= weft.treadling.length) this.fillUpTo(weft, thread);
newWeft.treadling[thread - 1].colour = editor.colour; weft.treadling[thread - 1].colour = this.props.colour;
updatePattern({ weft: newWeft }); this.props.updatePattern({ weft });
}; }
const mouseDownColourway = event => { mouseDownColourway = event => {
event.preventDefault(); event.preventDefault();
setDraggingColourway(true); this.draggingColourway = true;
}; }
const mouseUpColourway = event => setDraggingColourway(false); mouseUpColourway = event => this.draggingColourway = false;
const mouseMoveColourway = (event) => { mouseMoveColourway = (event) => {
if (draggingColourway) { if (this.draggingColourway) {
const newWeft = Object.assign({}, weft); const weft = Object.assign({}, this.props.weft);
const { thread } = getThreadTreadle(event); const { thread } = this.getThreadTreadle(event);
if (thread >= weft.treadling.length) fillUpTo(newWeft, thread); if (thread >= weft.treadling.length) this.fillUpTo(weft, thread);
newWeft.treadling[thread - 1].colour = editor.colour; weft.treadling[thread - 1].colour = this.props.colour;
updatePattern({ weft: newWeft }); this.props.updatePattern({ weft });
} }
}; }
const mouseUp = event => setDragging(false); mouseUp = event => this.dragging = false;
const mouseDown = (event) => { mouseDown = (event) => {
event.preventDefault(); event.preventDefault();
const { treadle, thread } = getThreadTreadle(event); const { treadle, thread } = this.getThreadTreadle(event);
setStartTreadle(treadle); this.startTreadle = treadle;
setStartThread(thread); this.startThread = thread;
setDragging(true); this.dragging = true;
}; }
const mouseMove = (event) => { mouseMove = (event) => {
if (dragging && editor.tool) { if (this.dragging && this.props.tool) {
const newWeft = Object.assign({}, weft); const weft = Object.assign({}, this.props.weft);
const { treadle, thread } = getThreadTreadle(event); const { treadle, thread } = this.getThreadTreadle(event);
let lX = startTreadle; let hX = treadle; let lY = startThread; let hY = thread; let lX = this.startTreadle; let hX = treadle; let lY = this.startThread; let
hY = thread;
let xDirection = 1; let let xDirection = 1; let
yDirection = 1; yDirection = 1;
if (treadle < startTreadle) { if (treadle < this.startTreadle) {
lX = treadle; lX = treadle;
hX = startTreadle; hX = this.startTreadle;
xDirection = -1; xDirection = -1;
} }
if (thread < startThread) { if (thread < this.startThread) {
lY = thread; lY = thread;
hY = startThread; hY = this.startThread;
yDirection = -1; yDirection = -1;
} }
let x = xDirection > 0 ? lX : hX; let x = xDirection > 0 ? lX : hX;
let y = yDirection > 0 ? lY : hY; let y = yDirection > 0 ? lY : hY;
if (editor.tool === 'colour') { if (this.props.tool === 'colour') {
if ((thread - 1) >= weft.treadling.length) fillUpTo(newWeft, (thread - 1)); if ((thread - 1) >= weft.treadling.length) this.fillUpTo(weft, (thread - 1));
newWeft.treadling[thread - 1].colour = editor.colour; weft.treadling[thread - 1].colour = this.props.colour;
} }
if (editor.tool === 'straight') { if (this.props.tool === 'straight') {
while (y <= hY && y >= lY) { while (y <= hY && y >= lY) {
if ((y - 1) >= weft.treadling.length || weft.treadling.length - y - 1 < 5) fillUpTo(newWeft, (y + 5)); if ((y - 1) >= weft.treadling.length || weft.treadling.length - y - 1 < 5) this.fillUpTo(weft, (y + 5));
newWeft.treadling[y - 1].treadle = x + 1; weft.treadling[y - 1].treadle = x + 1;
x += xDirection; x += xDirection;
y += yDirection; y += yDirection;
if (x > hX || x < lX) x = xDirection > 0 ? lX : hX; if (x > hX || x < lX) x = xDirection > 0 ? lX : hX;
} }
} }
if (editor.tool === 'point') { if (this.props.tool === 'point') {
while (y <= hY && y >= lY) { while (y <= hY && y >= lY) {
if ((y - 1) >= weft.treadling.length || weft.treadling.length - y -1 < 5) fillUpTo(newWeft, y + 5); if ((y - 1) >= weft.treadling.length || weft.treadling.length - y -1 < 5) this.fillUpTo(weft, y + 5);
newWeft.treadling[y - 1].treadle = x + 1; weft.treadling[y - 1].treadle = x + 1;
x += xDirection; x += xDirection;
y += yDirection; y += yDirection;
if (x > hX || x <= lX) xDirection = 0 - xDirection; if (x > hX || x <= lX) xDirection = 0 - xDirection;
} }
} }
updatePattern({ weft: newWeft }); this.props.updatePattern({ weft });
} }
}; }
const click = (event) => { click = (event) => {
if (editor.tool === 'point' || editor.tool === 'straight') { if (this.props.tool === 'point' || this.props.tool === 'straight') {
let { thread, treadle } = getThreadTreadle(event); let { thread, treadle } = this.getThreadTreadle(event);
treadle += 1; treadle += 1;
const newWeft = Object.assign({}, weft); const weft = Object.assign({}, this.props.weft);
if (thread >= newWeft.treadling.length || newWeft.treadling.length - thread < 5) fillUpTo(newWeft, thread + 5); if (thread >= weft.treadling.length || weft.treadling.length - thread < 5) this.fillUpTo(weft, thread + 5);
const weftThread = newWeft.treadling[thread - 1]; const weftThread = weft.treadling[thread - 1];
weftThread.treadle = weftThread.treadle === treadle ? 0 : treadle; weftThread.treadle = weftThread.treadle === treadle ? 0 : treadle;
updatePattern({ weft: newWeft }); this.props.updatePattern({ weft });
} }
}; }
const fillUpTo = (weft, limit) => { fillUpTo = (weft, limit) => {
let i = weft.treadling.length; let i = weft.treadling.length;
while (i <= limit) { while (i <= limit) {
weft.treadling.push({ treadle: 0 }); weft.treadling.push({ treadle: 0 });
weft.threads++; weft.threads++;
i++; i++;
} }
}; }
const getMarker = (size) => { getMarker(size) {
if (markers[size]) return markers[size]; if (this.markers[size]) return this.markers[size];
const m_canvas = document.createElement('canvas'); const m_canvas = document.createElement('canvas');
m_canvas.width = baseSize; m_canvas.width = this.props.baseSize;
m_canvas.height = baseSize; m_canvas.height = this.props.baseSize;
const mc = m_canvas.getContext('2d'); const mc = m_canvas.getContext('2d');
mc.fillStyle = 'black'; mc.fillStyle = 'black';
mc.fillRect(0, 0, baseSize, baseSize); mc.fillRect(0, 0, this.props.baseSize, this.props.baseSize);
markers[size] = m_canvas; this.markers[size] = m_canvas;
return m_canvas; return m_canvas;
}; }
const getSquare = (size, colour) => { getSquare(size, colour) {
if (squares[size] && squares[size][colour]) return squares[size][colour]; if (this.squares[size] && this.squares[size][colour]) return this.squares[size][colour];
const m_canvas = document.createElement('canvas'); const m_canvas = document.createElement('canvas');
m_canvas.width = 10; m_canvas.width = 10;
m_canvas.height = baseSize; m_canvas.height = this.props.baseSize;
const mc = m_canvas.getContext('2d'); const mc = m_canvas.getContext('2d');
mc.fillStyle = utils.rgb(colour); mc.fillStyle = utils.rgb(colour);
mc.fillRect(0, 0, 10, baseSize); mc.fillRect(0, 0, 10, this.props.baseSize);
if (!squares[size]) squares[size] = {}; if (!this.squares[size]) this.squares[size] = {};
squares[size][colour] = m_canvas; this.squares[size][colour] = m_canvas;
return m_canvas; return m_canvas;
}; }
const paintDrawdown = () => { paintDrawdown() {
const canvas = weftRef.current; const canvas = this.refs.weft;
const colourway = colourwayRef.current; const colourway = this.refs.colourway;
const ctx = canvas.getContext('2d');// , { alpha: false }); const ctx = canvas.getContext('2d');// , { alpha: false });
const ctx2 = colourway.getContext('2d');// , { alpha: false }); const ctx2 = colourway.getContext('2d');// , { alpha: false });
const { baseSize, weft } = this.props;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
@ -184,37 +200,49 @@ function Weft({ cellStyle, warp, weft, baseSize, updatePattern }) {
for (let thread = 0; thread < weft.threads; thread++) { for (let thread = 0; thread < weft.threads; thread++) {
const treadle = weft.treadling[thread].treadle; const treadle = weft.treadling[thread].treadle;
const marker = getMarker(baseSize); const marker = this.getMarker(baseSize);
ctx.drawImage(marker, ((treadle - 1) * baseSize), ((thread) * baseSize)); ctx.drawImage(marker, ((treadle - 1) * this.props.baseSize), ((thread) * this.props.baseSize));
const colourSquare = getSquare(baseSize, weft.treadling[thread].colour || weft.defaultColour); const colourSquare = this.getSquare(baseSize, weft.treadling[thread].colour || weft.defaultColour);
ctx2.drawImage(colourSquare, 0, (thread * baseSize)); ctx2.drawImage(colourSquare, 0, (thread * this.props.baseSize));
} }
} }
return (
<StyledWeft baseSize={baseSize} treadles={weft.treadles} shafts={warp.shafts} threads={weft.threads}>
<canvas className='weft-colourway' ref={colourwayRef} width={10} height={weft.threads * baseSize} render() {
style={{ position: 'absolute', top: 0, right: 0, width: 10, height: weft.threads * baseSize}} const { warp, weft, baseSize } = this.props;
onClick={mouseClickColourway} return (
onMouseDown={mouseDownColourway} <StyledWeft baseSize={baseSize} treadles={weft.treadles} shafts={warp.shafts} threads={weft.threads}>
onMouseMove={mouseMoveColourway} <canvas className='weft-colourway' ref="colourway" width={10} height={weft.threads * baseSize}
onMouseUp={mouseUpColourway} style={{ position: 'absolute', top: 0, right: 0, width: 10, height: weft.threads * baseSize}}
onMouseLeave={mouseUpColourway} onClick={this.mouseClickColourway}
/> onMouseDown={this.mouseDownColourway}
<canvas className='weft-threads joyride-weft' ref={weftRef} width={weft.treadles * baseSize} height={weft.threads * baseSize} onMouseMove={this.mouseMoveColourway}
style={{ onMouseUp={this.mouseUpColourway}
position: 'absolute', onMouseLeave={this.mouseUpColourway}
top: 0, right: 10, height: weft.threads * baseSize, width: weft.treadles * baseSize, />
borderRadius: 4, boxShadow: '0px 0px 10px rgba(0,0,0,0.15)', <canvas className='weft-threads joyride-weft' ref="weft" width={weft.treadles * baseSize} height={weft.threads * baseSize}
}} style={{
onClick={click} position: 'absolute',
onMouseDown={mouseDown} top: 0, right: 10, height: weft.threads * baseSize, width: weft.treadles * baseSize,
onMouseMove={mouseMove} borderRadius: 4, boxShadow: '0px 0px 10px rgba(0,0,0,0.15)',
onMouseUp={mouseUp} }}
onMouseLeave={mouseUp} onClick={this.click}
/> onMouseDown={this.mouseDown}
</StyledWeft> onMouseMove={this.mouseMove}
); onMouseUp={this.mouseUp}
onMouseLeave={this.mouseUp}
/>
</StyledWeft>
);
}
} }
export default Weft; const mapStateToProps = (state, ownProps) => state.objects.editor;
const mapDispatchToProps = dispatch => ({
});
const WeftContainer = connect(
mapStateToProps, mapDispatchToProps,
)(Weft);
export default WeftContainer;

View File

@ -1,18 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Table, Container } from 'semantic-ui-react'; import { Table, Container } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link, withRouter } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { connect } from 'react-redux';
import moment from 'moment'; import moment from 'moment';
import api from 'api'; import api from 'api';
function Root() { function Root({ user }) {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const { user } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user };
});
useEffect(() => { useEffect(() => {
if (!(user?.roles?.indexOf('root') > -1)) return; if (!(user?.roles?.indexOf('root') > -1)) return;
api.root.getUsers(({ users}) => { api.root.getUsers(({ users}) => {
@ -65,4 +60,15 @@ function Root() {
); );
} }
export default Root; const mapStateToProps = (state, ownProps) => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user };
};
const mapDispatchToProps = dispatch => ({
});
const RootContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(Root));
export default RootContainer;

View File

@ -1,29 +1,22 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Message, Form, Divider, Segment, Icon } from 'semantic-ui-react'; import { Message, Form, Divider, Segment, Icon } from 'semantic-ui-react';
import { useNavigate } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useSelector, useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
function AccountSettings() { function AccountSettings({ user, onLogout, history, onReceiveUser }) {
const [newEmail, setNewEmail] = useState(''); const [newEmail, setNewEmail] = useState('');
const [existingPassword, setExistingPassword] = useState(''); const [existingPassword, setExistingPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [deletePassword, setDeletePassword] = useState(''); const [deletePassword, setDeletePassword] = useState('');
const navigate = useNavigate();
const dispatch = useDispatch();
const { user } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user };
});
const updateEmail = () => { const updateEmail = () => {
api.accounts.updateEmail(newEmail, data => { api.accounts.updateEmail(newEmail, data => {
setNewEmail(''); setNewEmail('');
dispatch(actions.users.receive(Object.assign({}, user, { email: data.email }))); onReceiveUser(Object.assign({}, user, { email: data.email }));
}, err => toast.error(err.message)); }, err => toast.error(err.message));
} }
@ -39,8 +32,8 @@ function AccountSettings() {
const confirm = window.confirm('Really delete your account?'); const confirm = window.confirm('Really delete your account?');
if (!confirm) return; if (!confirm) return;
api.accounts.delete(deletePassword, () => { api.accounts.delete(deletePassword, () => {
api.auth.logout(() => dispatch(actions.auth.logout())); api.auth.logout(onLogout);
navigate('/'); history.push('/');
toast.info('Sorry to see you go'); toast.info('Sorry to see you go');
}, err => toast.error(err.message)); }, err => toast.error(err.message));
} }
@ -88,4 +81,16 @@ function AccountSettings() {
); );
} }
export default AccountSettings; const mapStateToProps = (state, ownProps) => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user };
};
const mapDispatchToProps = dispatch => ({
onReceiveUser: user => dispatch(actions.users.receive(user)),
onLogout: () => dispatch(actions.auth.logout()),
});
const AccountSettingsContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(AccountSettings));
export default AccountSettingsContainer;

View File

@ -0,0 +1,214 @@
import React, { Component } from 'react';
import { Loader, Dropdown, Modal, Icon, Message, Divider, Segment, Button, Card } from 'semantic-ui-react';
import { withRouter } from 'react-router-dom';
import { toast } from 'react-toastify';
import { connect } from 'react-redux';
import utils from 'utils/utils.js';
import actions from 'actions';
import api from 'api';
class BillingSettings extends Component {
constructor(props) {
super(props);
this.state = { card: null, planId: null };
}
componentDidMount() {
api.billing.get(({ planId, card }) => this.setState({ planId, card }));
}
getCardIcon = (brand) => {
if (brand === 'Visa') return 'visa';
if (brand === 'MasterCard') return 'mastercard';
if (brand === 'American Express') return 'amex';
return 'credit card';
}
loadStripe = () => {
this.setState({ showCardModal: true });
this.stripe = window.Stripe(process.env.REACT_APP_STRIPE_KEY);
const elements = this.stripe.elements(); this.card = elements.create('card', {
style: {
base: {
color: '#32325d',
lineHeight: '25px',
fontFamily: '"Lato", sans-serif',
fontSize: '20px',
'::placeholder': {
color: 'rgb(180,180,180)',
},
},
invalid: {
color: '#fa755a',
iconColor: '#fa755a',
},
},
});
setTimeout(() => this.card.mount('#stripe-card'), 500);
}
saveCard = () => {
this.setState({ cardLoading: true });
this.stripe.createToken(this.card).then((result) => {
if (result.error) {
this.setState({ cardLoading: false });
} else {
api.billing.updateCard(result, ({ card }) => {
this.setState({ showCardModal: false, cardLoading: false, card });
toast.info('Card updated successfully');
}, (err) => {
toast.error(err.message);
this.setState({ cardLoading: false });
});
}
});
}
deleteCard = () => {
utils.confirm('Really delete this payment card?', 'We recommend keeping a card on your account so you don\'t lose access to your projects when your plan is due for renewal.').then(() => {
this.setState({ cardLoading: true });
api.billing.deleteCard(() => {
this.setState({ card: null, cardLoading: false });
toast.info('Payment card deleted');
}, (err) => {
toast.error(err.message);
this.setState({ cardLoading: false });
});
}, () => {});
}
selectPlan = (planId) => {
const text = planId === 'free'
? 'Switching to the free plan will cancel your current subscription. Your account will be updated right away and you will not be charged again until you re-subscribe to a paid plan. Please note that you will lose access to paid-for features and may lose access to private projects.'
: 'Thank you for changing your plan! Your account will be updated right away. We will charge your card now, but if you are switching from another paid plan the payment will be prorated.';
utils.confirm('Changing your plan', `${text} Do you want to continue?`).then(() => {
this.setState({ planLoading: true });
api.billing.selectPlan(planId, (response) => {
this.setState({ planId: response.planId, planLoading: false });
this.props.onUpdatePlan(response.planId);
toast.info('Your plan was updated successfully');
}, (err) => {
this.setState({ planLoading: false });
toast.error(err.message);
});
}, () => {});
}
render() {
const { showCardModal, cardLoading, planLoading, planId, card } = this.state;
return (
<div>
<Segment>
<h3>Plan</h3>
<Card.Group stackable centered itemsPerRow={2} style={{ marginTop: 30, marginBottom: 30 }}>
{utils.plans.map(p => (
<Card key={p.id}>
<Card.Content>
<Card.Header textAlign="center">{p.name}</Card.Header>
<Card.Meta textAlign="center">{p.description}</Card.Meta>
<Card.Meta textAlign="center">
<strong>
{p.price} per month
</strong>
</Card.Meta>
<Divider hidden />
{p.features.map((f, i) => (
<p key={i}>
<Icon style={{ color: 'green' }} name="check circle outline" /> {f}
</p>
))}
</Card.Content>
<Card.Content extra>
{p.id === (planId || 'free')
? <Button color="teal" fluid icon="check" content="Selected" loading={planLoading} />
: <Button color="teal" basic fluid content="Activate" onClick={e => this.selectPlan(p.id)} loading={planLoading} />
}
</Card.Content>
</Card>
))}
</Card.Group>
</Segment>
<Segment>
<h3>Payment method</h3>
{card
? (
<Card color="green">
<Loader active={cardLoading} />
<Card.Content>
<h2><Icon name={`cc ${this.getCardIcon(card.brand)}`} /></h2>
<h3 style={{fontFamily: 'Monospace'}}>
**** **** **** {card.last4}
</h3>
</Card.Content>
<Card.Content extra>
<span style={{fontFamily: 'Monospace'}}>Expires {card.exp_month}/{card.exp_year}</span>
<Dropdown text="Options" style={{ float: 'right' }}>
<Dropdown.Menu>
<Dropdown.Item content="Update card details" icon="credit card" onClick={this.loadStripe} />
<Dropdown.Item content="Delete card" icon="trash" onClick={this.deleteCard} />
</Dropdown.Menu>
</Dropdown>
</Card.Content>
</Card>
)
: (
<Message>
<Message.Header>
<Icon name="credit card" /> You don't currently have a payment method setup
</Message.Header>
<p>To sign-up for a paid plan, or to ensure your access to your Treadl projects is un-interrupted, please add a payment card to your account.</p>
<h4>
<Icon name="visa" />
<Icon name="mastercard" />
<Icon name="amex" />
<Icon name="diners club" />
</h4>
<Button color="yellow" icon="credit card" content="Add a payment card" onClick={this.loadStripe} />
</Message>
)
}
<Modal open={showCardModal}>
<Modal.Header>Add a card to your account</Modal.Header>
<Modal.Content image>
<Modal.Description>
<Message>
<Message.Content>
<Message.Header>
<Icon name="lock" /> Secure payments
</Message.Header>
<p>We use Stripe <Icon name="stripe" /> to handle payments. Treadl never sees or stores your full card details.</p>
</Message.Content>
</Message>
<Divider hidden />
<p><strong>Your card details</strong></p>
<div id="stripe-card" />
<Divider hidden />
<p>Adding a card does not mean you will be charged right away. We just use it to collect payment when you're on a paid-for plan.</p>
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button basic content="Cancel" onClick={e => this.setState({ showCardModal: false })} />
<Button color="teal" content="Save card to your account" onClick={this.saveCard} loading={cardLoading} />
</Modal.Actions>
</Modal>
</Segment>
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user };
};
const mapDispatchToProps = dispatch => ({
onUpdatePlan: plan => dispatch(actions.auth.updatePlan(plan)),
});
const BillingSettingsContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(BillingSettings));
export default BillingSettingsContainer;

View File

@ -1,44 +1,56 @@
import React, { useState } from 'react'; import React, { Component } from 'react';
import { Message, Input, Segment, Button } from 'semantic-ui-react'; import { Message, Input, Segment, Button } from 'semantic-ui-react';
import { withRouter } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useSelector, useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
function IdentitySettings() { class IdentitySettings extends Component {
const [newUsername, setNewUsername] = useState(''); constructor(props) {
const dispatch = useDispatch(); super(props);
const { user } = useSelector(state => { this.state = { newUsername: '' };
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]; }
return { user };
});
const updateUsername = () => { updateUsername = () => {
api.users.update(user.username, { username: newUsername }, (user) => { api.users.update(this.props.user.username, { username: this.state.newUsername }, (user) => {
dispatch(actions.users.updateUsername(user._id, newUsername)); this.props.onUpdateUsername(this.props.user._id, this.state.newUsername);
toast.info('Username updated'); toast.info('Username updated');
setNewUsername(''); this.setState({ newUsername: '' });
}, err => toast.error(err.message)); }, err => toast.error(err.message));
}; }
return ( render() {
<Segment raised color="blue"> return (
<h3>Username</h3> <Segment raised color="blue">
<p>Change the username you use for your profile and to login to Treadl.</p> <h3>Username</h3>
<p>Change the username you use for your profile and to login to Treadl.</p>
<Message> <Message>
<p><strong>Changing your username can have unintended side-effects</strong></p> <p><strong>Changing your username can have unintended side-effects</strong></p>
<p>Project URLs and other links are associated with your username, so any links to projects you've shared may not work correctly after you've changed your username.</p> <p>Project URLs and other links are associated with your username, so any links to projects you've shared may not work correctly after you've changed your username.</p>
<p>If you change your username, your old one will become available to be taken by somebody else.</p> <p>If you change your username, your old one will become available to be taken by somebody else.</p>
</Message> </Message>
<Input <Input
type="text" value={newUsername} type="text" value={this.state.newUsername}
onChange={e => setNewUsername(e.target.value)} onChange={e => this.setState({ newUsername: e.target.value })}
action=<Button color="yellow" content="Set new username" onClick={updateUsername} /> action=<Button color="yellow" content="Set new username" onClick={this.updateUsername} />
/> />
</Segment> </Segment>
); );
}
} }
export default IdentitySettings; const mapStateToProps = (state, ownProps) => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user };
};
const mapDispatchToProps = dispatch => ({
onUpdateUsername: (id, username) => dispatch(actions.users.updateUsername(id, username)),
});
const IdentitySettingsContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(IdentitySettings));
export default IdentitySettingsContainer;

View File

@ -1,7 +1,9 @@
import React from 'react'; import React, { Component } from 'react';
import { Label, Table, Checkbox, Divider, Segment } from 'semantic-ui-react'; import { Label, Table, Checkbox, Divider, Segment } from 'semantic-ui-react';
import { withRouter } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useSelector, useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
import utils from 'utils/utils.js'; import utils from 'utils/utils.js';
@ -14,58 +16,68 @@ const subs = [
{ key: 'projects.commented', text: 'Someone comments on one of your projects'}, { key: 'projects.commented', text: 'Someone comments on one of your projects'},
]; ];
function NotificationSettings() { class NotificationSettings extends Component {
const dispatch = useDispatch();
const { user, groups } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
return { user, groups };
});
const hasEmailSub = (key) => utils.hasSubscription(user, key); hasEmailSub = (key) => utils.hasSubscription(this.props.user, key);
const toggleEmailSub = (key, enable) => { toggleEmailSub = (key, enable) => {
const { user, onSubsUpdated } = this.props;
if (enable) if (enable)
api.users.createEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message)); api.users.createEmailSubscription(user.username, key, ({ subscriptions }) => onSubsUpdated(user._id, subscriptions), err => toast.error(err.message));
else else
api.users.deleteEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message)); api.users.deleteEmailSubscription(user.username, key, ({ subscriptions }) => onSubsUpdated(user._id, subscriptions), err => toast.error(err.message));
}; }
return ( render() {
<Segment raised color="blue"> const { groups } = this.props;
<h3>Email preferences</h3> return (
<p>Customise which automated emails you'd like to receive from Treadl.</p> <Segment raised color="blue">
<Divider hidden /> <h3>Email preferences</h3>
<p>Customise which automated emails you'd like to receive from Treadl.</p>
<Divider hidden />
<Table fluid basic='very'> <Table fluid basic='very'>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell>Notification type</Table.HeaderCell> <Table.HeaderCell>Notification type</Table.HeaderCell>
<Table.HeaderCell collapsing>Enabled</Table.HeaderCell> <Table.HeaderCell collapsing>Enabled</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell><Label color='orange' size='small'>Required</Label> Important account emails<br /><small>For example, password-resets, email address change warnings, and so on.</small>
</Table.Cell>
<Table.Cell><Checkbox toggle disabled checked={true}/></Table.Cell>
</Table.Row>
{subs.map(s =>
<Table.Row key={s.key}>
<Table.Cell>{s.text}</Table.Cell>
<Table.Cell><Checkbox toggle onChange={(e, c) => toggleEmailSub(s.key, c.checked)} checked={hasEmailSub(s.key)}/></Table.Cell>
</Table.Row> </Table.Row>
)} </Table.Header>
{groups.map(g => <Table.Body>
<Table.Row key={g._id}> <Table.Row>
<Table.Cell>Someone writes in the Notice Board of {g.name}</Table.Cell> <Table.Cell><Label color='orange' size='small'>Required</Label> Important account emails<br /><small>For example, password-resets, email address change warnings, and so on.</small>
<Table.Cell><Checkbox toggle onChange={(e, c) => toggleEmailSub(`groupFeed-${g._id}`, c.checked)} checked={hasEmailSub(`groupFeed-${g._id}`)}/></Table.Cell>
</Table.Cell>
<Table.Cell><Checkbox toggle disabled checked={true}/></Table.Cell>
</Table.Row> </Table.Row>
)} {subs.map(s =>
</Table.Body> <Table.Row key={s.key}>
</Table> <Table.Cell>{s.text}</Table.Cell>
</Segment> <Table.Cell><Checkbox toggle onChange={(e, c) => this.toggleEmailSub(s.key, c.checked)} checked={this.hasEmailSub(s.key)}/></Table.Cell>
); </Table.Row>
)}
{groups.map(g =>
<Table.Row key={g._id}>
<Table.Cell>Someone writes in the Notice Board of {g.name}</Table.Cell>
<Table.Cell><Checkbox toggle onChange={(e, c) => this.toggleEmailSub(`groupFeed-${g._id}`, c.checked)} checked={this.hasEmailSub(`groupFeed-${g._id}`)}/></Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
</Segment>
);
}
} }
export default NotificationSettings; const mapStateToProps = (state, ownProps) => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
return { user, groups };
};
const mapDispatchToProps = dispatch => ({
onSubsUpdated: (id, subs) => dispatch(actions.users.updateSubscriptions(id, subs)),
});
const NotificationSettingsContainer = withRouter(connect(
mapStateToProps, mapDispatchToProps,
)(NotificationSettings));
export default NotificationSettingsContainer;

View File

@ -1,27 +1,43 @@
import React from 'react'; import React, { Component } from 'react';
import { Divider, Container, Grid, Menu } from 'semantic-ui-react'; import {
import { Outlet, NavLink } from 'react-router-dom'; Divider, Container, Grid, Menu,
} from 'semantic-ui-react';
import { Link, Switch, Route } from 'react-router-dom';
function Settings() { import Identity from './Identity';
return ( import Notification from './Notification';
<Container style={{ marginTop: '40px' }}> import Billing from './Billing';
<h2>Manage your account</h2> import Account from './Account';
<Divider hidden />
<Grid stackable>
<Grid.Column computer={4}>
<Menu fluid vertical tabular>
<Menu.Item as={NavLink} to="/settings/identity" name="identity" icon="user secret" />
<Menu.Item as={NavLink} to='/settings/notifications' content='Notifications' icon='envelope' />
<Menu.Item as={NavLink} to="/settings/account" name="Account" icon="key" />
</Menu>
</Grid.Column>
<Grid.Column stretched width={12}> class Settings extends Component {
<Outlet /> render() {
</Grid.Column> return (
</Grid> <Container style={{ marginTop: '40px' }}>
</Container> <h2>Manage your account</h2>
); <Divider hidden />
<Grid stackable>
<Grid.Column computer={4}>
<Menu fluid vertical tabular>
<Menu.Item as={Link} to="/settings/identity" name="identity" active={this.props.location.pathname === '/settings/identity' || this.props.location.pathname === '/settings'} icon="user secret" />
<Menu.Item as={Link} to='/settings/notifications' content='Notifications' icon='envelope' active={this.props.location.pathname === '/settings/notifications'} />
{/*<Menu.Item as={Link} to="/settings/billing" content="Plan &amp; billing" icon="credit card" active={this.props.location.pathname === '/settings/billing'} />*/}
<Menu.Item as={Link} to="/settings/account" name="Account" active={this.props.location.pathname === '/settings/account'} icon="key" />
</Menu>
</Grid.Column>
<Grid.Column stretched width={12}>
<Switch>
<Route path="/settings/notifications" component={Notification} />
<Route path="/settings/account" component={Account} />
<Route path="/settings/billing" component={Billing} />
<Route path="/settings" component={Identity} />
</Switch>
</Grid.Column>
</Grid>
</Container>
);
}
} }
export default Settings; export default Settings;

View File

@ -1,123 +1,133 @@
import React from 'react'; import React, { Component } from 'react';
import { import {
Icon, Segment, Form, Button, Divider, Icon, Segment, Form, Button, Divider,
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Link, useParams } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux'; import { connect } from 'react-redux';
import utils from 'utils/utils'; import utils from 'utils/utils';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
import FileChooser from 'components/includes/FileChooser'; import FileChooser from 'components/includes/FileChooser';
function EditProfile() { class EditProfile extends Component {
const { username } = useParams(); updatePicture = (avatar) => {
const dispatch = useDispatch(); api.users.update(this.props.profileUser.username, { avatar }, this.props.onReceiveUser);
const { profileUser } = useSelector(state => { }
const users = state.users.users;
const profileUser = users.filter(u => username === u.username)[0];
return { profileUser };
});
const updatePicture = (avatar) => { updateProfile = () => {
api.users.update(profileUser.username, { avatar }, u => dispatch(actions.users.receive(u))); const { bio, location, website } = this.props.profileUser;
}; api.users.update(this.props.profileUser.username, { bio, location, website }, (user) => {
this.props.onReceiveUser(user);
const updateProfile = () => {
const { bio, location, website } = profileUser;
api.users.update(profileUser.username, { bio, location, website }, (u) => {
dispatch(actions.users.receive(u));
toast.info('Profile saved'); toast.info('Profile saved');
}, err => toast.error(err.message)); }, err => toast.error(err.message));
}; }
const updateSocial = () => { updateSocial = () => {
const { twitter, facebook, linkedIn, instagram } = profileUser; const { twitter, facebook, linkedIn, instagram } = this.props.profileUser;
api.users.update(profileUser.username, { twitter, facebook, linkedIn, instagram }, (u) => { api.users.update(this.props.profileUser.username, { twitter, facebook, linkedIn, instagram }, (user) => {
dispatch(actions.users.receive(u)); this.props.onReceiveUser(user);
toast.info('Profile saved'); toast.info('Profile saved');
}, err => toast.error(err.message)); }, err => toast.error(err.message));
}; }
return ( render() {
<div> const { profileUser, onUserEdited } = this.props;
<Link to={`/${profileUser.username}`} className="ui basic button"> return (
<Icon name="arrow left" /> Back to profile <div>
</Link> <Link to={`/${profileUser.username}`} className="ui basic button">
<h3>Edit public profile</h3> <Icon name="arrow left" /> Back to profile
<Divider hidden /> </Link>
<h3>Edit public profile</h3>
<Segment raised color="blue">
<h3>Essentials</h3>
<h5>Profile picture</h5>
<FileChooser
forType="user" forObject={profileUser}
trigger=<Button basic color="yellow" icon="image" content="Choose an image" />
accept="image/*" onComplete={f => updatePicture(f.storedName)}
/>
<h4>Or choose one of ours:</h4>
{utils.defaultAvatars().map(a => (
<img
alt="Default avatar" key={a.key} src={a.url}
style={{
width: 40, height: 40, margin: 4, cursor: 'pointer',
}}
onClick={e => updatePicture(a.key)}
/>
))}
<Divider hidden /> <Divider hidden />
<Form> <Segment raised color="blue">
<Form.TextArea <h3>Essentials</h3>
label="Bio" placeholder="Write a bit about yourself..."
value={profileUser.bio}
onChange={e => dispatch(actions.users.update(profileUser._id, { bio: e.target.value }))}
/>
<Form.Input
label="Location" placeholder="Where in the world are you?" icon="map pin" iconPosition="left"
value={profileUser.location}
onChange={e => dispatch(actions.users.update(profileUser._id, { location: e.target.value }))}
/>
<Form.Input
label="Website or URL" placeholder="https://yourwebsite.com" icon="globe" iconPosition="left"
value={profileUser.website}
onChange={e => dispatch(actions.users.update(profileUser._id, { website: e.target.value }))}
/>
<Form.Button color="yellow" content="Save profile" onClick={updateProfile} />
</Form>
</Segment>
<Segment raised color="blue"> <h5>Profile picture</h5>
<h3>Social</h3> <FileChooser
<p>Let others know where else they can find you.</p> forType="user" for={profileUser}
<Form> trigger=<Button basic color="yellow" icon="image" content="Choose an image" />
<Form.Input accept="image/*" onComplete={f => this.updatePicture(f.storedName)}
label="Twitter" placeholder="@username" icon="twitter" iconPosition="left"
value={profileUser.twitter}
onChange={e => dispatch(actions.users.update(profileUser._id, { twitter: e.target.value }))}
/> />
<Form.Input <h4>Or choose one of ours:</h4>
label="Facebook" placeholder="username" icon="facebook" iconPosition="left" {utils.defaultAvatars().map(a => (
value={profileUser.facebook} <img
onChange={e => dispatch(actions.users.update(profileUser._id, { facebook: e.target.value }))} alt="Default avatar" key={a.key} src={a.url}
/> style={{
<Form.Input width: 40, height: 40, margin: 4, cursor: 'pointer',
label="Instagram" placeholder="username" icon="instagram" iconPosition="left" }}
value={profileUser.instagram} onClick={e => this.updatePicture(a.key)}
onChange={e => dispatch(actions.users.update(profileUser._id, { instagram: e.target.value }))} />
/> ))}
<Form.Input <Divider hidden />
label="LinkedIn" placeholder="username" icon="linkedin" iconPosition="left"
value={profileUser.linkedIn}
onChange={e => dispatch(actions.users.update(profileUser._id, { linkedIn: e.target.value }))}
/>
<Form.Button color="yellow" content="Save social profiles" onClick={updateSocial} />
</Form>
</Segment>
</div>); <Form>
<Form.TextArea
label="Bio" placeholder="Write a bit about yourself..."
value={profileUser.bio}
onChange={e => onUserEdited(profileUser._id, { bio: e.target.value })}
/>
<Form.Input
label="Location" placeholder="Where in the world are you?" icon="map pin" iconPosition="left"
value={profileUser.location}
onChange={e => onUserEdited(profileUser._id, { location: e.target.value })}
/>
<Form.Input
label="Website or URL" placeholder="https://yourwebsite.com" icon="globe" iconPosition="left"
value={profileUser.website}
onChange={e => onUserEdited(profileUser._id, { website: e.target.value })}
/>
<Form.Button color="yellow" content="Save profile" onClick={this.updateProfile} />
</Form>
</Segment>
<Segment raised color="blue">
<h3>Social</h3>
<p>Let others know where else they can find you.</p>
<Form>
<Form.Input
label="Twitter" placeholder="@username" icon="twitter" iconPosition="left"
value={profileUser.twitter}
onChange={e => onUserEdited(profileUser._id, { twitter: e.target.value })}
/>
<Form.Input
label="Facebook" placeholder="username" icon="facebook" iconPosition="left"
value={profileUser.facebook}
onChange={e => onUserEdited(profileUser._id, { facebook: e.target.value })}
/>
<Form.Input
label="Instagram" placeholder="username" icon="instagram" iconPosition="left"
value={profileUser.instagram}
onChange={e => onUserEdited(profileUser._id, { instagram: e.target.value })}
/>
<Form.Input
label="LinkedIn" placeholder="username" icon="linkedin" iconPosition="left"
value={profileUser.linkedIn}
onChange={e => onUserEdited(profileUser._id, { linkedIn: e.target.value })}
/>
<Form.Button color="yellow" content="Save social profiles" onClick={this.updateSocial} />
</Form>
</Segment>
</div>);
}
} }
export default EditProfile; const mapStateToProps = (state, ownProps) => {
const users = state.users.users;
const profileUser = users.filter(u => ownProps.match.params.username === u.username)[0];
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user, profileUser };
};
const mapDispatchToProps = dispatch => ({
onUserEdited: (id, data) => dispatch(actions.users.update(id, data)),
onReceiveUser: user => dispatch(actions.users.receive(user)),
});
const EditProfileContainer = connect(
mapStateToProps,
mapDispatchToProps,
)(EditProfile);
export default EditProfileContainer;

View File

@ -1,174 +1,198 @@
import React, { useState, useEffect } from 'react'; import React, { Component } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Loader, Icon, List, Container, Card, Grid, Message } from 'semantic-ui-react'; import { Loader, Icon, List, Container, Card, Grid, Message } from 'semantic-ui-react';
import { Link, Outlet, useParams } from 'react-router-dom'; import { Link, Switch, Route } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux'; import { connect } from 'react-redux';
import moment from 'moment'; import moment from 'moment';
import utils from 'utils/utils'; import utils from 'utils/utils';
import actions from 'actions'; import actions from 'actions';
import api from 'api'; import api from 'api';
import EditProfile from './EditProfile';
import ProfileProjects from './ProfileProjects';
import BlurrableImage from 'components/includes/BlurrableImage'; import BlurrableImage from 'components/includes/BlurrableImage';
function Profile() { class Profile extends Component {
const [loading, setLoading] = useState(false); constructor(props) {
const dispatch = useDispatch(); super(props);
const { username } = useParams(); this.state = { loading: false };
}
const { user, profileUser, errorMessage } = useSelector(state => { componentDidMount() {
const users = state.users.users; this.setState({ loading: true });
const profileUser = users.filter(u => username === u.username)[0]; api.users.get(this.props.match.params.username, user => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]; this.props.onReceiveUser(user);
return { user, profileUser, errorMessage: state.users.errorMessage }; this.setState({ loading: false });
});
useEffect(() => {
setLoading(true);
api.users.get(username, user => {
dispatch(actions.users.receive(user));
setLoading(false);
}, err => { }, err => {
dispatch(actions.users.requestFailed(err)); this.props.onRequestFailed(err);
setLoading(false); this.setState({ loading: false });
}); });
}, [dispatch, username]) }
return ( componentDidUpdate(prevProps) {
<Container style={{ marginTop: '40px' }}> if (this.props.match.params.username !== prevProps.match.params.username) {
<Helmet title={profileUser?.username ? `${profileUser.username}'s Profile` : 'Profile'} /> api.users.get(this.props.match.params.username, this.props.onReceiveUser);
{loading && !profileUser && }
<div style={{textAlign: 'center'}}> }
<h4>Loading {username}'s profile...</h4>
<Loader active inline="centered" />
</div>
}
{errorMessage && (
<Message>
<p><strong>There was a problem finding this page.</strong></p>
<p>{errorMessage}</p>
</Message>
)}
{profileUser
&& (
<Grid stackable>
<Grid.Column computer={5}>
<Card.Group centered>
<Card raised color="yellow">
<Card.Content>
<div style={{textAlign: 'center'}}>
<BlurrableImage
src={utils.cropUrl(utils.avatarUrl(profileUser), 200, 200)}
blurHash={profileUser.avatarBlurHash}
width={200} height={200}
style={{ borderRadius: '50%' }}
/>
</div>
<Card.Header>{profileUser.username}</Card.Header>
<Card.Meta>
<span className="date">
Joined {moment(profileUser.createdAt).fromNow()}
</span>
</Card.Meta>
</Card.Content>
{profileUser.location
&& (
<Card.Content extra textAlign="right">
<Icon name="map pin" /> {profileUser.location}
</Card.Content>
)
}
{user && user._id === profileUser._id
&& (
<Card.Content extra textAlign="right">
<Link to={`/${profileUser.username}/edit`}>
<Icon name="pencil" /> Edit profile
</Link>
</Card.Content>
)
}
</Card>
{(profileUser.bio || profileUser.website || profileUser.twitter || profileUser.facebook) && render() {
<Card fluid> const { loading } = this.state;
const { user, profileUser, errorMessage } = this.props;
return (
<Container style={{ marginTop: '40px' }}>
<Helmet title={profileUser?.username ? `${profileUser.username}'s Profile` : 'Profile'} />
{loading && !profileUser &&
<div style={{textAlign: 'center'}}>
<h4>Loading {this.props.match.params.username}'s profile...</h4>
<Loader active inline="centered" />
</div>
}
{errorMessage && (
<Message>
<p><strong>There was a problem finding this page.</strong></p>
<p>{errorMessage}</p>
</Message>
)}
{profileUser
&& (
<Grid stackable>
<Grid.Column computer={5}>
<Card.Group centered>
<Card raised color="yellow">
<Card.Content> <Card.Content>
<Card.Header> <div style={{textAlign: 'center'}}>
About {profileUser.username} <BlurrableImage
</Card.Header> src={utils.cropUrl(utils.avatarUrl(profileUser), 200, 200)}
</Card.Content> blurHash={profileUser.avatarBlurHash}
<Card.Content> width={200} height={200}
<p>{profileUser.bio}</p> style={{ borderRadius: '50%' }}
<List relaxed> />
{profileUser.website </div>
&& ( <Card.Header>{profileUser.username}</Card.Header>
<a href={utils.ensureHttp(profileUser.website)} target="_blank" className="item" rel="noopener noreferrer"> <Card.Meta>
<List.Icon name="globe" size="large" verticalAlign="middle" /> <span className="date">
<List.Content> Joined {moment(profileUser.createdAt).fromNow()}
<List.Header>{profileUser.website}</List.Header> </span>
</List.Content> </Card.Meta>
</a>
)
}
{profileUser.twitter
&& (
<a href={`https://twitter.com/${profileUser.twitter.replace(/@/g, '')}`} target="_blank" className="item" rel="noopener noreferrer">
<List.Icon name="twitter" size="large" verticalAlign="middle" />
<List.Content>
<List.Header>
@{profileUser.twitter.replace(/@/g, '')}
</List.Header>
<List.Description>Twitter</List.Description>
</List.Content>
</a>
)
}
{profileUser.facebook
&& (
<a href={`https://facebook.com/${profileUser.facebook}`} target="_blank" className="item" rel="noopener noreferrer">
<List.Icon name="facebook" size="large" verticalAlign="middle" />
<List.Content>
<List.Header>{profileUser.facebook}</List.Header>
<List.Description>Facebook</List.Description>
</List.Content>
</a>
)
}
{profileUser.instagram
&& (
<a href={`https://instagram.com/${profileUser.facebook}`} target="_blank" className="item" rel="noopener noreferrer">
<List.Icon name="instagram" size="large" verticalAlign="middle" />
<List.Content>
<List.Header>{profileUser.instagram}</List.Header>
<List.Description>Instagram</List.Description>
</List.Content>
</a>
)
}
{profileUser.linkedIn
&& (
<a href={`https://linkedin.com/in/${profileUser.linkedIn}`} target="_blank" className="item" rel="noopener noreferrer">
<List.Icon name="linkedin" size="large" verticalAlign="middle" />
<List.Content>
<List.Header>{profileUser.linkedIn}</List.Header>
<List.Description>LinkedIn</List.Description>
</List.Content>
</a>
)
}
</List>
</Card.Content> </Card.Content>
{profileUser.location
&& (
<Card.Content extra textAlign="right">
<Icon name="map pin" /> {profileUser.location}
</Card.Content>
)
}
{user && user._id === profileUser._id
&& (
<Card.Content extra textAlign="right">
<Link to={`/${profileUser.username}/edit`}>
<Icon name="pencil" /> Edit profile
</Link>
</Card.Content>
)
}
</Card> </Card>
}
</Card.Group>
</Grid.Column>
<Grid.Column computer={11}> {(profileUser.bio || profileUser.website || profileUser.twitter || profileUser.facebook) &&
<Outlet /> <Card fluid>
</Grid.Column> <Card.Content>
</Grid> <Card.Header>
) About {profileUser.username}
} </Card.Header>
</Container> </Card.Content>
); <Card.Content>
<p>{profileUser.bio}</p>
<List relaxed>
{profileUser.website
&& (
<a href={utils.ensureHttp(profileUser.website)} target="_blank" className="item" rel="noopener noreferrer">
<List.Icon name="globe" size="large" verticalAlign="middle" />
<List.Content>
<List.Header>{profileUser.website}</List.Header>
</List.Content>
</a>
)
}
{profileUser.twitter
&& (
<a href={`https://twitter.com/${profileUser.twitter.replace(/@/g, '')}`} target="_blank" className="item" rel="noopener noreferrer">
<List.Icon name="twitter" size="large" verticalAlign="middle" />
<List.Content>
<List.Header>
@{profileUser.twitter.replace(/@/g, '')}
</List.Header>
<List.Description>Twitter</List.Description>
</List.Content>
</a>
)
}
{profileUser.facebook
&& (
<a href={`https://facebook.com/${profileUser.facebook}`} target="_blank" className="item" rel="noopener noreferrer">
<List.Icon name="facebook" size="large" verticalAlign="middle" />
<List.Content>
<List.Header>{profileUser.facebook}</List.Header>
<List.Description>Facebook</List.Description>
</List.Content>
</a>
)
}
{profileUser.instagram
&& (
<a href={`https://instagram.com/${profileUser.facebook}`} target="_blank" className="item" rel="noopener noreferrer">
<List.Icon name="instagram" size="large" verticalAlign="middle" />
<List.Content>
<List.Header>{profileUser.instagram}</List.Header>
<List.Description>Instagram</List.Description>
</List.Content>
</a>
)
}
{profileUser.linkedIn
&& (
<a href={`https://linkedin.com/in/${profileUser.linkedIn}`} target="_blank" className="item" rel="noopener noreferrer">
<List.Icon name="linkedin" size="large" verticalAlign="middle" />
<List.Content>
<List.Header>{profileUser.linkedIn}</List.Header>
<List.Description>LinkedIn</List.Description>
</List.Content>
</a>
)
}
</List>
</Card.Content>
</Card>
}
</Card.Group>
</Grid.Column>
<Grid.Column computer={11}>
<Switch>
<Route path="/:username/edit" component={EditProfile} />
<Route component={ProfileProjects} />
</Switch>
</Grid.Column>
</Grid>
)
}
</Container>
);
}
} }
export default Profile; const mapStateToProps = (state, ownProps) => {
const users = state.users.users;
const profileUser = users.filter(u => ownProps.match.params.username === u.username)[0];
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user, profileUser, loading: state.users.loading, errorMessage: state.users.errorMessage };
};
const mapDispatchToProps = dispatch => ({
onRequestUser: () => dispatch(actions.users.request()),
onRequestFailed: err => dispatch(actions.users.requestFailed(err)),
onReceiveUser: user => dispatch(actions.users.receive(user)),
});
const ProfileContainer = connect(
mapStateToProps,
mapDispatchToProps,
)(Profile);
export default ProfileContainer;

View File

@ -1,37 +1,42 @@
import React from 'react'; import React, { Component } from 'react';
import { Icon, Divider, Card, Message } from 'semantic-ui-react'; import { Icon, Divider, Card, Message } from 'semantic-ui-react';
import { useSelector } from 'react-redux'; import { connect } from 'react-redux';
import { useParams } from 'react-router-dom';
import ProjectCard from 'components/includes/ProjectCard'; import ProjectCard from 'components/includes/ProjectCard';
function ProfileProjects() { class ProfileProjects extends Component {
const { username } = useParams(); render() {
const { profileUser } = useSelector(state => { const { profileUser } = this.props;
const users = state.users.users; return (
const profileUser = users.filter(u => u.username === username)[0]; <div>
return { profileUser }; <h3><Icon name='book' /> {profileUser.username}'s projects</h3>
}); <Divider hidden />
{(profileUser.projects && profileUser.projects.length > 0)
return ( ? (
<div> <Card.Group itemsPerRow={3} stackable>
<h3><Icon name='book' /> {profileUser.username}'s projects</h3> {profileUser.projects.map(proj => (
<Divider hidden /> <ProjectCard key={proj._id} project={proj} />
{(profileUser.projects && profileUser.projects.length > 0) ))}
? ( </Card.Group>
<Card.Group itemsPerRow={3} stackable> )
{profileUser.projects.map(proj => ( : (
<ProjectCard key={proj._id} project={proj} /> <Message>
))} <span role="img" aria-label="monkey hiding eyes">🙈</span> {profileUser.username} doesn't have any projects yet.
</Card.Group> </Message>
) )
: ( }
<Message> </div>);
<span role="img" aria-label="monkey hiding eyes">🙈</span> {profileUser.username} doesn't have any projects yet. }
</Message>
)
}
</div>);
} }
export default ProfileProjects; const mapStateToProps = (state, ownProps) => {
const users = state.users.users;
const profileUser = users.filter(u => u.username === ownProps.match.params.username)[0];
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user, profileUser };
};
const ProfileProjectsContainer = connect(
mapStateToProps,
)(ProfileProjects);
export default ProfileProjectsContainer;

View File

@ -0,0 +1,93 @@
import React, { Component } from 'react';
import { Message, Card, Icon, Divider, Button, Container } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { connect } from 'react-redux';
import api from 'api';
class Pricing extends Component {
constructor(props) {
super(props);
this.state = { plans: [] };
}
componentDidMount() {
api.billing.getPlans(plans => this.setState({ plans }));
}
render() {
const { user } = this.props;
return (
<Container style={{ marginTop: 30 }}>
<h1>Pricing</h1>
<p>Creating a Treadl account is free, but you can choose to pay for extra features. We only require card details when you decide to subscribe to a paid plan.</p>
<Card.Group itemsPerRow={2}>
<Card>
<Card.Content>
<Card.Header>How come it's free?</Card.Header>
<p>Treadl offers several key features to free account-holders. For many people, the free account will be enough, but others may want to subscribe to a paid plan for extra features.</p>
</Card.Content>
</Card>
<Card>
<Card.Content>
<Card.Header>You own your data</Card.Header>
<p>We will never claim any ownership of your own content that you create using Treadl or upload to your Treadl account - even for free accounts.</p>
</Card.Content>
</Card>
</Card.Group>
<Divider section />
<h2>Plans</h2>
<p>You can select and subscribe to a plan after you've created a free account.</p>
{user &&
<Message>
<p>{user.username}, you can update your plan at any time by visiting your billing settings page.</p>
<Button as={Link} to='/settings/billing' color='blue' icon='settings' content='Billing settings' />
</Message>
}
<Card.Group stackable centered itemsPerRow={3} style={{ marginTop: 30, marginBottom: 30 }}>
{this.state.plans.map(p => (
<Card key={p.id}>
<Card.Content textAlign="center">
<Card.Header>{p.name}</Card.Header>
<Card.Meta>{p.description}</Card.Meta>
<Card.Meta><strong>{p.price} per month</strong>
</Card.Meta>
</Card.Content>
<Card.Content>
{p.features.map((f, i) => (
<p key={i}>
<Icon style={{ color: 'green' }} name="check circle outline" /> {f}
</p>
))}
</Card.Content>
{!user &&
<Card.Content extra>
<Button fluid size="small" color="teal" content="Create your account" onClick={this.props.onRegisterClicked} />
</Card.Content>
}
</Card>
))}
</Card.Group>
{!user &&
<div>
<h2>Interested to find out more?</h2>
<p>Create your free account today to see what it's about.</p>
<Button color="teal" content="Sign-up" onClick={this.props.onRegisterClicked} />
</div>
}
</Container>
);
}
}
const mapStateToProps = (state, ownProps) => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user };
};
const PricingContainer = connect(
mapStateToProps,
)(Pricing);
export default PricingContainer;

View File

@ -1,76 +1,78 @@
import React from 'react'; import React, { Component } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Container } from 'semantic-ui-react'; import { Container } from 'semantic-ui-react';
function PrivacyPolicy() { class PrivacyPolicy extends Component {
return ( render() {
<Container style={{ marginTop: 50, marginBottom: 50 }}> return (
<Helmet title='Privacy Policy' /> <Container style={{ marginTop: 50, marginBottom: 50 }}>
<h1>Privacy Policy</h1> <Helmet title='Privacy Policy' />
<p>This policy is designed to be accessible, understandable, and easy to read without legal and other jargon. If you have any comments, questions, or concerns about this policy, please get in touch with us by emailing hello@treadl.com.</p> <h1>Privacy Policy</h1>
<p>This document will have slight changes made to it occasionally. Please refer back to it from time to time.</p> <p>This policy is designed to be accessible, understandable, and easy to read without legal and other jargon. If you have any comments, questions, or concerns about this policy, please get in touch with us by emailing hello@treadl.com.</p>
<p>This policy governs the use and protection of personal data of people (users, you, etc.) using Treadl.</p> <p>This document will have slight changes made to it occasionally. Please refer back to it from time to time.</p>
<p>Data protection refers to the responsible security of personal data and transparency in the way we handle and process such data. Personal data is information that - on its own or in conjunction with other data - can be used to identify an individual person.</p> <p>This policy governs the use and protection of personal data of people (users, you, etc.) using Treadl.</p>
<p>Data protection refers to the responsible security of personal data and transparency in the way we handle and process such data. Personal data is information that - on its own or in conjunction with other data - can be used to identify an individual person.</p>
<h2>Complaints</h2> <h2>Complaints</h2>
<p>If you would like to complain about this policy, or how we may have treated a request from you with respect to data protection, then please get in touch with us in the first case so that we can help rectify the problem. In other cases, you may also want to get in touch with the Information Comissioner's Office (ICO), who may be able to provide you with more information and support. Their website is at https://ico.org.uk.</p> <p>If you would like to complain about this policy, or how we may have treated a request from you with respect to data protection, then please get in touch with us in the first case so that we can help rectify the problem. In other cases, you may also want to get in touch with the Information Comissioner's Office (ICO), who may be able to provide you with more information and support. Their website is at https://ico.org.uk.</p>
<h2>What data does Treadl collect?</h2> <h2>What data does Treadl collect?</h2>
<h3>When visiting and browsing our website</h3> <h3>When visiting and browsing our website</h3>
<p>When you visit us using a web browser, we collect some data about your computer and the way our services are used by you, even if you don't have an account. We do not collect your name or other details about you at this stage, but we may process information such as your computing devices location, its IP address, and details about relevant software your device may be running. This data is processed by Simple Analytics (for tracking aggregated use of our services, so that we can better understand how to improve our services for their audiences). The legal basis for processing this data is a legitimate interest in recording aggregated analytics data for improvement purposes and to see how often people visit our website.</p> <p>When you visit us using a web browser, we collect some data about your computer and the way our services are used by you, even if you don't have an account. We do not collect your name or other details about you at this stage, but we may process information such as your computing devices location, its IP address, and details about relevant software your device may be running. This data is processed by Simple Analytics (for tracking aggregated use of our services, so that we can better understand how to improve our services for their audiences). The legal basis for processing this data is a legitimate interest in recording aggregated analytics data for improvement purposes and to see how often people visit our website.</p>
<h3>When sending us an email</h3> <h3>When sending us an email</h3>
<p>Sometimes you may wish to send an email to us or reply to an email we have sent you. Any emails received will be treated in confidence and kept securely. Strong passwords and multi-factor authentication is implemented on all email accounts that can receive such emails.</p> <p>Sometimes you may wish to send an email to us or reply to an email we have sent you. Any emails received will be treated in confidence and kept securely. Strong passwords and multi-factor authentication is implemented on all email accounts that can receive such emails.</p>
<h3>When signing-up for a Treadl account</h3> <h3>When signing-up for a Treadl account</h3>
<p>Treadl allows you to register for an account. This is the primary way by which we collect personal data from you, since such data is needed in order to identify you when you want to login and use these services. We may also use your email address to update you on platform updates and notifications, which you can control. When signing-up we collect an email address, username, and password. Once registered, you can choose to fill in additional profile data, such as social media links, a bio, and more. We ask for consent to this policy when creating an account, and the legal basis for processing this data is a legitimate interest in being able to provide services to you.</p> <p>Treadl allows you to register for an account. This is the primary way by which we collect personal data from you, since such data is needed in order to identify you when you want to login and use these services. We may also use your email address to update you on platform updates and notifications, which you can control. When signing-up we collect an email address, username, and password. Once registered, you can choose to fill in additional profile data, such as social media links, a bio, and more. We ask for consent to this policy when creating an account, and the legal basis for processing this data is a legitimate interest in being able to provide services to you.</p>
<h3>When using Treadl</h3> <h3>When using Treadl</h3>
<p>Posts, content, comments, patterns, files and any other data you add to or upload to Treadl as part of its standard use are also collected. This is for the purposes of providing services to you. To use Treadl, you will have provided consent to this policy during the registration process, and the legal basis for processing this data is a legitimate interest in being able to provide core services to you.</p> <p>Posts, content, comments, patterns, files and any other data you add to or upload to Treadl as part of its standard use are also collected. This is for the purposes of providing services to you. To use Treadl, you will have provided consent to this policy during the registration process, and the legal basis for processing this data is a legitimate interest in being able to provide core services to you.</p>
<h2>Who has access to your data?</h2> <h2>Who has access to your data?</h2>
<p>Staff operating Treadl can view accounts and account data. This is with the exception of passwords, which are fully encrypted.</p> <p>Staff operating Treadl can view accounts and account data. This is with the exception of passwords, which are fully encrypted.</p>
<p>Other users and visitors to Treadl will also be able to see the profile data and content that you have made public. Your username and other profile data is always available to other people.</p> <p>Other users and visitors to Treadl will also be able to see the profile data and content that you have made public. Your username and other profile data is always available to other people.</p>
<p>In order to provide access to our services to users, we also sometimes need to pass pieces of your personal data to third-party services (known as data processors for the purposes of the GDPR). We only ever do this when we need to, and only send the minimum amount of information required. We ensure that the processors' own privacy policies follow suitable data protection practices. Our current data processors are:</p> <p>In order to provide access to our services to users, we also sometimes need to pass pieces of your personal data to third-party services (known as data processors for the purposes of the GDPR). We only ever do this when we need to, and only send the minimum amount of information required. We ensure that the processors' own privacy policies follow suitable data protection practices. Our current data processors are:</p>
<ul> <ul>
<li>Mailgun (for sending mail). We provide Mailgun with your email address so that the mail can be delivered.</li> <li>Mailgun (for sending mail). We provide Mailgun with your email address so that the mail can be delivered.</li>
<li>Drift (for providing support). If you get in touch with us via the Drift chat widget, it may collect your email address for ongoing support provision.</li> <li>Drift (for providing support). If you get in touch with us via the Drift chat widget, it may collect your email address for ongoing support provision.</li>
<li>Simple Analytics (for aggregated visitor analytics). This service may collect information about your browser and the location you visit us from.</li> <li>Simple Analytics (for aggregated visitor analytics). This service may collect information about your browser and the location you visit us from.</li>
</ul> </ul>
<h2>How long do we keep your data for?</h2> <h2>How long do we keep your data for?</h2>
<p>We keep your account data (email and username) and content produced by your account (e.g. posts, comments, projects) for as long as your account is active. You can fully and irreversibly delete your account (and its associated data) at any time. Please note that data held in backup systems may be held for up to 30 days after such an event before it too is deleted.</p> <p>We keep your account data (email and username) and content produced by your account (e.g. posts, comments, projects) for as long as your account is active. You can fully and irreversibly delete your account (and its associated data) at any time. Please note that data held in backup systems may be held for up to 30 days after such an event before it too is deleted.</p>
<h2>Where is your data stored?</h2> <h2>Where is your data stored?</h2>
<p>Our databases and servers are all based in the UK, and so your data will be processed fully within the UK. We use Mailgun's EU based servers for transmitting mail, and other processors may process subsets of your data in the US, if you choose to use those features.</p> <p>Our databases and servers are all based in the UK, and so your data will be processed fully within the UK. We use Mailgun's EU based servers for transmitting mail, and other processors may process subsets of your data in the US, if you choose to use those features.</p>
<h2>How do we protect your data?</h2> <h2>How do we protect your data?</h2>
<p>All data is encrypted during transmission (e.g. between your device and our servers, and between our servers), and when stored ("encrypted at rest"). Our servers are well-protected with industry standard security measures.</p> <p>All data is encrypted during transmission (e.g. between your device and our servers, and between our servers), and when stored ("encrypted at rest"). Our servers are well-protected with industry standard security measures.</p>
<h2>Child safety</h2> <h2>Child safety</h2>
<p>Children under the age of 16 are not allowed to use Treadl or to directly provide us with personal data. As such, we do not knowingly store or process personal data relating to children under the age of 16.</p> <p>Children under the age of 16 are not allowed to use Treadl or to directly provide us with personal data. As such, we do not knowingly store or process personal data relating to children under the age of 16.</p>
<h2>Your rights</h2> <h2>Your rights</h2>
<p>We take the handling of personal data very seriously, and we want to make sure that you are aware of your rights under this policy. If your wish to invoke your rights requires us to complete some action on your behalf (for example, to stop processing your data), then we will always deal with your request in total confidence, at no cost, and as soon as we can (within 30 days of receiving your request).</p> <p>We take the handling of personal data very seriously, and we want to make sure that you are aware of your rights under this policy. If your wish to invoke your rights requires us to complete some action on your behalf (for example, to stop processing your data), then we will always deal with your request in total confidence, at no cost, and as soon as we can (within 30 days of receiving your request).</p>
<h3>Right to be informed</h3> <h3>Right to be informed</h3>
<p>You have a right to know about how we handle and process your personal data. This Privacy Policy aims to fulfil this Right, but please email us if you have further questions or concerns.</p> <p>You have a right to know about how we handle and process your personal data. This Privacy Policy aims to fulfil this Right, but please email us if you have further questions or concerns.</p>
<h3>Right of access</h3> <h3>Right of access</h3>
<p>You have a right to know if we store or process your personal data and to obtain access to the personal data about you that we, or any data processors that process data on our behalf, have about you. To obtain this information, please email us.</p> <p>You have a right to know if we store or process your personal data and to obtain access to the personal data about you that we, or any data processors that process data on our behalf, have about you. To obtain this information, please email us.</p>
<h3>Right to rectification</h3> <h3>Right to rectification</h3>
<p>You have a right to have personal data we keep or process about you rectified. If data we have about you is incorrect or incomplete, then please email us with details of any corrections to be made.</p> <p>You have a right to have personal data we keep or process about you rectified. If data we have about you is incorrect or incomplete, then please email us with details of any corrections to be made.</p>
<h3>Right to erasure</h3> <h3>Right to erasure</h3>
<p>You have the right to have all of your personal data erased, which will prevent any further storage or processing any of your personal data on our behalf, and will sometimes result in a necessary deletion of any accounts you hold with us. In many cases, deleting any accounts you hold with us will erase your details. However, if you wish to make sure of this, then please email us with details of your request.</p> <p>You have the right to have all of your personal data erased, which will prevent any further storage or processing any of your personal data on our behalf, and will sometimes result in a necessary deletion of any accounts you hold with us. In many cases, deleting any accounts you hold with us will erase your details. However, if you wish to make sure of this, then please email us with details of your request.</p>
<h3>Right to restrict processing</h3> <h3>Right to restrict processing</h3>
<p>You have the right to halt the processing of your personal data in the way that you choose. For example, you may wish to maintain an account with us but no longer want us to use one of our data processors to process your data. To restrict the processing of your personal data, please email us with details of your request.</p> <p>You have the right to halt the processing of your personal data in the way that you choose. For example, you may wish to maintain an account with us but no longer want us to use one of our data processors to process your data. To restrict the processing of your personal data, please email us with details of your request.</p>
<h3>Right to data portability</h3> <h3>Right to data portability</h3>
<p>You have the right to obtain personal data we have or process about you in a format that is useful to you or to another service you would like to use with your data. We are happy to provide data to you in formats including CSV, JSON, PDF, Microsoft Word, and more. Please email us with details of your request.</p> <p>You have the right to obtain personal data we have or process about you in a format that is useful to you or to another service you would like to use with your data. We are happy to provide data to you in formats including CSV, JSON, PDF, Microsoft Word, and more. Please email us with details of your request.</p>
<h3>Right to object</h3> <h3>Right to object</h3>
<p>You have a right to object to the processing of your personal data in particular ways. For example, for marketing or profiling purposes. If you would like to object to our processing of your data, then please email us.</p> <p>You have a right to object to the processing of your personal data in particular ways. For example, for marketing or profiling purposes. If you would like to object to our processing of your data, then please email us.</p>
<h3>Rights related to automated decision making including profiling</h3> <h3>Rights related to automated decision making including profiling</h3>
<p>We do not use personal data for automated decision making, and does not use such data for profiling users. Additionally, any processing done for analytics and reporting is done on an entirely anonymous basis. For more information or if you have any concerns, please email us.</p> <p>We do not use personal data for automated decision making, and does not use such data for profiling users. Additionally, any processing done for analytics and reporting is done on an entirely anonymous basis. For more information or if you have any concerns, please email us.</p>
</Container> </Container>
); );
}
} }
export default PrivacyPolicy; export default PrivacyPolicy;

View File

@ -1,42 +1,44 @@
import React from 'react'; import React, { Component } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Container } from 'semantic-ui-react'; import { Container } from 'semantic-ui-react';
function TermsOfUse() { class TermsOfUse extends Component {
return ( render() {
<Container style={{ marginTop: 50, marginBottom: 50 }}> return (
<Helmet title='Terms of Use' /> <Container style={{ marginTop: 50, marginBottom: 50 }}>
<h1>Treadl Terms of Use</h1> <Helmet title='Terms of Use' />
<p>This policy is designed to be accessible, understandable, and easy to read without legal and other jargon. If you have any comments, questions, or concerns about this policy, please get in touch with us by emailing hello@treadl.com.</p> <h1>Treadl Terms of Use</h1>
<p>This document will have slight changes made to it occasionally. Please refer back to it from time to time.</p> <p>This policy is designed to be accessible, understandable, and easy to read without legal and other jargon. If you have any comments, questions, or concerns about this policy, please get in touch with us by emailing hello@treadl.com.</p>
<p>This document will have slight changes made to it occasionally. Please refer back to it from time to time.</p>
<h2>Terms</h2> <h2>Terms</h2>
<p>Treadl does not guarantee constant availability of service access and accepts no liability for downtime or access failure due to circumstances beyond its reasonable control (including any failure by ISP or system provider).</p> <p>Treadl does not guarantee constant availability of service access and accepts no liability for downtime or access failure due to circumstances beyond its reasonable control (including any failure by ISP or system provider).</p>
<p>The services may contain links to other sites on the internet. We are not responsible for the accuracy, legality, decency of material or copyright compliance of any such linked websites or services or information provided via any such link.</p> <p>The services may contain links to other sites on the internet. We are not responsible for the accuracy, legality, decency of material or copyright compliance of any such linked websites or services or information provided via any such link.</p>
<p>No data transmission over the Internet can be guaranteed as totally secure. Whilst we strive to protect such information, we do not warrant and cannot ensure the security of information which you transmit to us. Accordingly, any information which you transmit to us is transmitted at your own risk.</p> <p>No data transmission over the Internet can be guaranteed as totally secure. Whilst we strive to protect such information, we do not warrant and cannot ensure the security of information which you transmit to us. Accordingly, any information which you transmit to us is transmitted at your own risk.</p>
<p>Any information on our services may include technical inaccuracies or typographical errors. We strive to maintain accuracy as much as possible.</p> <p>Any information on our services may include technical inaccuracies or typographical errors. We strive to maintain accuracy as much as possible.</p>
<p>We distribute the content supplied by our users. We try to maintain a safe platform for all of our users, but cannot take responsibility for such content. We are not liable for any damages as a result of such content. Self-policing is a an important feature of platforms like Treadl, so please report any problems with such user-generated content to the email address above. We accept no liability or responsibility to any person or organisation as a consequence of any reliance upon the information contained by our services.</p> <p>We distribute the content supplied by our users. We try to maintain a safe platform for all of our users, but cannot take responsibility for such content. We are not liable for any damages as a result of such content. Self-policing is a an important feature of platforms like Treadl, so please report any problems with such user-generated content to the email address above. We accept no liability or responsibility to any person or organisation as a consequence of any reliance upon the information contained by our services.</p>
<p>Our services are provided on an as is, as available basis without warranties of any kind, express or implied, including, but not limited to, those of TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE or NON-INFRINGEMENT or any warranty arising from a course of dealing, usage, or trade practice. No advice or written information provided shall create a warranty; nor shall members or visitors to our services rely on any such information or advice.</p> <p>Our services are provided on an as is, as available basis without warranties of any kind, express or implied, including, but not limited to, those of TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE or NON-INFRINGEMENT or any warranty arising from a course of dealing, usage, or trade practice. No advice or written information provided shall create a warranty; nor shall members or visitors to our services rely on any such information or advice.</p>
<p>We reserve the right to permanently ban any user from our services for any reason related to mis-behaviour. We will be the sole judge of behavior and we do not offer appeals or refunds in those cases.</p> <p>We reserve the right to permanently ban any user from our services for any reason related to mis-behaviour. We will be the sole judge of behavior and we do not offer appeals or refunds in those cases.</p>
<p>All users must follow the Treadl code of conduct:</p> <p>All users must follow the Treadl code of conduct:</p>
<ul> <ul>
<li>Do not upload or add content that contains slurs, racist, homophobic, transphobic, ableist or otherwise discriminatory content. Do not add or upload content containing any hateful ideologies.</li> <li>Do not upload or add content that contains slurs, racist, homophobic, transphobic, ableist or otherwise discriminatory content. Do not add or upload content containing any hateful ideologies.</li>
<li>Be kind to others, and don't be intentionally difficult or antagonistic. Harassment or threats or personal attacks will not be tolerated in any way.</li> <li>Be kind to others, and don't be intentionally difficult or antagonistic. Harassment or threats or personal attacks will not be tolerated in any way.</li>
<li>Do not post any adult content.</li> <li>Do not post any adult content.</li>
<li>Do not post any illegal (from the perspective of EU law) or copyrighted content for which you do not own the rights.</li> <li>Do not post any illegal (from the perspective of EU law) or copyrighted content for which you do not own the rights.</li>
<li>Do not post any content, or interact with the platform, in a way that is designed to bring harm to the platform or to negatively affect other people.</li> <li>Do not post any content, or interact with the platform, in a way that is designed to bring harm to the platform or to negatively affect other people.</li>
</ul> </ul>
</Container> </Container>
); );
}
} }
export default TermsOfUse; export default TermsOfUse;

View File

@ -1,17 +1,19 @@
import 'react-app-polyfill/ie9'; import 'react-app-polyfill/ie9';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom'; import { Switch, Route, Router } from 'react-router-dom';
import { createStore } from 'redux'; import { createStore } from 'redux';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing'; import { BrowserTracing } from '@sentry/tracing';
import createBrowserHistory from 'history/createBrowserHistory';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import 'pell/dist/pell.min.css'; import 'pell/dist/pell.min.css';
import './index.css'; import './index.css';
import reducers from './reducers'; import reducers from './reducers';
import App from './components/App.js'; import App from './components/App.js';
import DraftExport from 'components/main/projects/objects/DraftExport';
import * as serviceWorker from './registerServiceWorker'; import * as serviceWorker from './registerServiceWorker';
export const store = createStore(reducers); export const store = createStore(reducers);
@ -26,9 +28,12 @@ if (process.env.REACT_APP_SENTRY_DSN) {
ReactDOM.render( ReactDOM.render(
<Provider store={store}> <Provider store={store}>
<BrowserRouter> <Router history={createBrowserHistory()}>
<App /> <Switch>
</BrowserRouter> <Route path="/objects/:id/export" component={DraftExport} />
<Route component={App} />
</Switch>
</Router>
</Provider>, </Provider>,
document.getElementById('root'), document.getElementById('root'),
); );

View File

@ -1110,20 +1110,13 @@
dependencies: dependencies:
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4": "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4":
version "7.11.2" version "7.11.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
dependencies: dependencies:
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.12.1", "@babel/runtime@^7.7.6":
version "7.17.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72"
integrity sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.10.4", "@babel/template@^7.4.0", "@babel/template@^7.8.6": "@babel/template@^7.10.4", "@babel/template@^7.4.0", "@babel/template@^7.8.6":
version "7.10.4" version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278"
@ -1636,13 +1629,6 @@
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
"@types/debug@^4.0.0":
version "4.1.7"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82"
integrity sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==
dependencies:
"@types/ms" "*"
"@types/eslint-visitor-keys@^1.0.0": "@types/eslint-visitor-keys@^1.0.0":
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
@ -1656,21 +1642,6 @@
"@types/minimatch" "*" "@types/minimatch" "*"
"@types/node" "*" "@types/node" "*"
"@types/hast@^2.0.0":
version "2.3.4"
resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc"
integrity sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g==
dependencies:
"@types/unist" "*"
"@types/hoist-non-react-statics@^3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
dependencies:
"@types/react" "*"
hoist-non-react-statics "^3.3.0"
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0":
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762"
@ -1696,28 +1667,11 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd"
integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ== integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ==
"@types/mdast@^3.0.0":
version "3.0.10"
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.10.tgz#4724244a82a4598884cbbe9bcfd73dff927ee8af"
integrity sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA==
dependencies:
"@types/unist" "*"
"@types/mdurl@^1.0.0":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9"
integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==
"@types/minimatch@*": "@types/minimatch@*":
version "3.0.3" version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
"@types/ms@*":
version "0.7.31"
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197"
integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
"@types/node@*": "@types/node@*":
version "14.6.0" version "14.6.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.0.tgz#7d4411bf5157339337d7cff864d9ff45f177b499" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.0.tgz#7d4411bf5157339337d7cff864d9ff45f177b499"
@ -1728,45 +1682,16 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
"@types/prop-types@*", "@types/prop-types@^15.0.0":
version "15.7.5"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
"@types/q@^1.5.1": "@types/q@^1.5.1":
version "1.5.4" version "1.5.4"
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24"
integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==
"@types/react@*":
version "18.0.8"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.8.tgz#a051eb380a9fbcaa404550543c58e1cf5ce4ab87"
integrity sha512-+j2hk9BzCOrrOSJASi5XiOyBbERk9jG5O73Ya4M0env5Ixi6vUNli4qy994AINcEF+1IEHISYFfIT4zwr++LKw==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/scheduler@*":
version "0.16.2"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
"@types/stack-utils@^1.0.1": "@types/stack-utils@^1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
"@types/unist@*", "@types/unist@^2.0.0":
version "2.0.6"
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
"@types/use-sync-external-store@^0.0.3":
version "0.0.3"
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
"@types/yargs-parser@*": "@types/yargs-parser@*":
version "15.0.0" version "15.0.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d"
@ -2529,11 +2454,6 @@ babylon@^6.18.0:
resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ== integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==
bail@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d"
integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==
balanced-match@^1.0.0: balanced-match@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
@ -2991,11 +2911,6 @@ chalk@^4.1.0:
ansi-styles "^4.1.0" ansi-styles "^4.1.0"
supports-color "^7.1.0" supports-color "^7.1.0"
character-entities@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.1.tgz#98724833e1e27990dee0bd0f2b8a859c3476aac7"
integrity sha512-OzmutCf2Kmc+6DrFrrPS8/tDh2+DpnrfzdICHWhcVC9eOd0N1PXmQEE1a8iM4IziIAG+8tmTq3K+oo0ubH6RRQ==
chardet@^0.7.0: chardet@^0.7.0:
version "0.7.0" version "0.7.0"
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
@ -3221,11 +3136,6 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
dependencies: dependencies:
delayed-stream "~1.0.0" delayed-stream "~1.0.0"
comma-separated-tokens@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.2.tgz#d4c25abb679b7751c880be623c1179780fe1dd98"
integrity sha512-G5yTt3KQN4Yn7Yk4ed73hlZ1evrFKXeUW3086p3PRFNp7m2vIjI6Pg+Kgb+oyzhd9F2qdcoj67+y3SdxL5XWsg==
commander@^2.11.0, commander@^2.20.0: commander@^2.11.0, commander@^2.20.0:
version "2.20.3" version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
@ -3749,11 +3659,6 @@ cssstyle@^1.0.0, cssstyle@^1.1.1:
dependencies: dependencies:
cssom "0.3.x" cssom "0.3.x"
csstype@^3.0.2:
version "3.0.11"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.11.tgz#d66700c5eacfac1940deb4e3ee5642792d85cd33"
integrity sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==
cyclist@^1.0.1: cyclist@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
@ -3802,13 +3707,6 @@ debug@^3.1.1, debug@^3.2.5:
dependencies: dependencies:
ms "^2.1.1" ms "^2.1.1"
debug@^4.0.0:
version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
dependencies:
ms "2.1.2"
debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: debug@^4.0.1, debug@^4.1.0, debug@^4.1.1:
version "4.1.1" version "4.1.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
@ -3821,13 +3719,6 @@ decamelize@^1.2.0:
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
decode-named-character-reference@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.1.tgz#57b2bd9112659cacbc449d3577d7dadb8e1f3d1b"
integrity sha512-YV/0HQHreRwKb7uBopyIkLG17jG6Sv2qUchk9qSoVJ2f+flwRsPNBO0hAnjt6mTNYUT+vw9Gy2ihXg4sUWPi2w==
dependencies:
character-entities "^2.0.0"
decode-uri-component@^0.2.0: decode-uri-component@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
@ -3920,11 +3811,6 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
dequal@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.2.tgz#85ca22025e3a87e65ef75a7a437b35284a7e319d"
integrity sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==
des.js@^1.0.0: des.js@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843" resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843"
@ -3961,11 +3847,6 @@ diff-sequences@^24.9.0:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5"
integrity sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew== integrity sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew==
diff@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
diffie-hellman@^5.0.0: diffie-hellman@^5.0.0:
version "5.0.3" version "5.0.3"
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"
@ -4713,7 +4594,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
assign-symbols "^1.0.0" assign-symbols "^1.0.0"
is-extendable "^1.0.1" is-extendable "^1.0.1"
extend@^3.0.0, extend@~3.0.2: extend@~3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
@ -5348,11 +5229,6 @@ hash.js@^1.0.0, hash.js@^1.0.3:
inherits "^2.0.3" inherits "^2.0.3"
minimalistic-assert "^1.0.1" minimalistic-assert "^1.0.1"
hast-util-whitespace@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-2.0.0.tgz#4fc1086467cc1ef5ba20673cb6b03cec3a970f1c"
integrity sha512-Pkw+xBHuV6xFeJprJe2BBEoDV+AvQySaz3pPDRUs5PNZEMQjpXJJueqrpcHIXxnWTcAGi/UOCgVShlkY6kLoqg==
he@^1.2.0: he@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
@ -5363,7 +5239,7 @@ hex-color-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==
history@^4.7.2: history@^4.7.2, history@^4.9.0:
version "4.10.1" version "4.10.1"
resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3"
integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==
@ -5375,13 +5251,6 @@ history@^4.7.2:
tiny-warning "^1.0.0" tiny-warning "^1.0.0"
value-equal "^1.0.1" value-equal "^1.0.1"
history@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b"
integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==
dependencies:
"@babel/runtime" "^7.7.6"
hmac-drbg@^1.0.0: hmac-drbg@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@ -5391,7 +5260,7 @@ hmac-drbg@^1.0.0:
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1" minimalistic-crypto-utils "^1.0.1"
hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
version "3.3.2" version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
@ -5699,11 +5568,6 @@ ini@^1.3.5:
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
inline-style-parser@0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1"
integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==
inquirer@7.0.4: inquirer@7.0.4:
version "7.0.4" version "7.0.4"
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.4.tgz#99af5bde47153abca23f5c7fc30db247f39da703" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.4.tgz#99af5bde47153abca23f5c7fc30db247f39da703"
@ -5844,11 +5708,6 @@ is-buffer@^1.0.2, is-buffer@^1.1.5:
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
is-buffer@^2.0.0:
version "2.0.5"
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191"
integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==
is-callable@^1.1.4, is-callable@^1.2.0: is-callable@^1.1.4, is-callable@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb"
@ -6024,11 +5883,6 @@ is-plain-obj@^1.0.0:
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4=
is-plain-obj@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.0.0.tgz#06c0999fd7574edf5a906ba5644ad0feb3a84d22"
integrity sha512-NXRbBtUdBioI73y/HmOhogw/U5msYPC9DAtGkJXeFcFWSFZw0mCUsPxk/snTuJHzNKA8kLBK4rH97RMB1BfCXw==
is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
@ -6104,6 +5958,11 @@ is-wsl@^2.1.1:
dependencies: dependencies:
is-docker "^2.0.0" is-docker "^2.0.0"
isarray@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@ -6787,11 +6646,6 @@ kleur@^3.0.3:
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
kleur@^4.0.3:
version "4.1.4"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.4.tgz#8c202987d7e577766d039a8cd461934c01cda04d"
integrity sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==
last-call-webpack-plugin@^3.0.0: last-call-webpack-plugin@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555" resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555"
@ -6966,7 +6820,7 @@ loglevel@^1.6.6:
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171"
integrity sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA== integrity sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA==
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.4.0: loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@ -7042,54 +6896,6 @@ md5.js@^1.3.4:
inherits "^2.0.1" inherits "^2.0.1"
safe-buffer "^5.1.2" safe-buffer "^5.1.2"
mdast-util-definitions@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-5.1.0.tgz#b6d10ef00a3c4cf191e8d9a5fa58d7f4a366f817"
integrity sha512-5hcR7FL2EuZ4q6lLMUK5w4lHT2H3vqL9quPvYZ/Ku5iifrirfMHiGdhxdXMUbUkDmz5I+TYMd7nbaxUhbQkfpQ==
dependencies:
"@types/mdast" "^3.0.0"
"@types/unist" "^2.0.0"
unist-util-visit "^3.0.0"
mdast-util-from-markdown@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-1.2.0.tgz#84df2924ccc6c995dec1e2368b2b208ad0a76268"
integrity sha512-iZJyyvKD1+K7QX1b5jXdE7Sc5dtoTry1vzV28UZZe8Z1xVnB/czKntJ7ZAkG0tANqRnBF6p3p7GpU1y19DTf2Q==
dependencies:
"@types/mdast" "^3.0.0"
"@types/unist" "^2.0.0"
decode-named-character-reference "^1.0.0"
mdast-util-to-string "^3.1.0"
micromark "^3.0.0"
micromark-util-decode-numeric-character-reference "^1.0.0"
micromark-util-decode-string "^1.0.0"
micromark-util-normalize-identifier "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
unist-util-stringify-position "^3.0.0"
uvu "^0.5.0"
mdast-util-to-hast@^12.1.0:
version "12.1.1"
resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-12.1.1.tgz#89a2bb405eaf3b05eb8bf45157678f35eef5dbca"
integrity sha512-qE09zD6ylVP14jV4mjLIhDBOrpFdShHZcEsYvvKGABlr9mGbV7mTlRWdoFxL/EYSTNDiC9GZXy7y8Shgb9Dtzw==
dependencies:
"@types/hast" "^2.0.0"
"@types/mdast" "^3.0.0"
"@types/mdurl" "^1.0.0"
mdast-util-definitions "^5.0.0"
mdurl "^1.0.0"
micromark-util-sanitize-uri "^1.0.0"
unist-builder "^3.0.0"
unist-util-generated "^2.0.0"
unist-util-position "^4.0.0"
unist-util-visit "^4.0.0"
mdast-util-to-string@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-3.1.0.tgz#56c506d065fbf769515235e577b5a261552d56e9"
integrity sha512-n4Vypz/DZgwo0iMHLQL49dJzlp7YtAJP+N07MZHpjPf/5XJuHUWstviF4Mn2jEiR/GNmtnRRqnwsXExk3igfFA==
mdn-data@2.0.4: mdn-data@2.0.4:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b"
@ -7100,11 +6906,6 @@ mdn-data@2.0.6:
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978"
integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA== integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA==
mdurl@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=
media-typer@0.3.0: media-typer@0.3.0:
version "0.3.0" version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@ -7169,201 +6970,6 @@ microevent.ts@~0.1.1:
resolved "https://registry.yarnpkg.com/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0" resolved "https://registry.yarnpkg.com/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0"
integrity sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g== integrity sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g==
micromark-core-commonmark@^1.0.1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.0.6.tgz#edff4c72e5993d93724a3c206970f5a15b0585ad"
integrity sha512-K+PkJTxqjFfSNkfAhp4GB+cZPfQd6dxtTXnf+RjZOV7T4EEXnvgzOcnp+eSTmpGk9d1S9sL6/lqrgSNn/s0HZA==
dependencies:
decode-named-character-reference "^1.0.0"
micromark-factory-destination "^1.0.0"
micromark-factory-label "^1.0.0"
micromark-factory-space "^1.0.0"
micromark-factory-title "^1.0.0"
micromark-factory-whitespace "^1.0.0"
micromark-util-character "^1.0.0"
micromark-util-chunked "^1.0.0"
micromark-util-classify-character "^1.0.0"
micromark-util-html-tag-name "^1.0.0"
micromark-util-normalize-identifier "^1.0.0"
micromark-util-resolve-all "^1.0.0"
micromark-util-subtokenize "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.1"
uvu "^0.5.0"
micromark-factory-destination@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-1.0.0.tgz#fef1cb59ad4997c496f887b6977aa3034a5a277e"
integrity sha512-eUBA7Rs1/xtTVun9TmV3gjfPz2wEwgK5R5xcbIM5ZYAtvGF6JkyaDsj0agx8urXnO31tEO6Ug83iVH3tdedLnw==
dependencies:
micromark-util-character "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
micromark-factory-label@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-1.0.2.tgz#6be2551fa8d13542fcbbac478258fb7a20047137"
integrity sha512-CTIwxlOnU7dEshXDQ+dsr2n+yxpP0+fn271pu0bwDIS8uqfFcumXpj5mLn3hSC8iw2MUr6Gx8EcKng1dD7i6hg==
dependencies:
micromark-util-character "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
uvu "^0.5.0"
micromark-factory-space@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-1.0.0.tgz#cebff49968f2b9616c0fcb239e96685cb9497633"
integrity sha512-qUmqs4kj9a5yBnk3JMLyjtWYN6Mzfcx8uJfi5XAveBniDevmZasdGBba5b4QsvRcAkmvGo5ACmSUmyGiKTLZew==
dependencies:
micromark-util-character "^1.0.0"
micromark-util-types "^1.0.0"
micromark-factory-title@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-1.0.2.tgz#7e09287c3748ff1693930f176e1c4a328382494f"
integrity sha512-zily+Nr4yFqgMGRKLpTVsNl5L4PMu485fGFDOQJQBl2NFpjGte1e86zC0da93wf97jrc4+2G2GQudFMHn3IX+A==
dependencies:
micromark-factory-space "^1.0.0"
micromark-util-character "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
uvu "^0.5.0"
micromark-factory-whitespace@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-1.0.0.tgz#e991e043ad376c1ba52f4e49858ce0794678621c"
integrity sha512-Qx7uEyahU1lt1RnsECBiuEbfr9INjQTGa6Err+gF3g0Tx4YEviPbqqGKNv/NrBaE7dVHdn1bVZKM/n5I/Bak7A==
dependencies:
micromark-factory-space "^1.0.0"
micromark-util-character "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
micromark-util-character@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-1.1.0.tgz#d97c54d5742a0d9611a68ca0cd4124331f264d86"
integrity sha512-agJ5B3unGNJ9rJvADMJ5ZiYjBRyDpzKAOk01Kpi1TKhlT1APx3XZk6eN7RtSz1erbWHC2L8T3xLZ81wdtGRZzg==
dependencies:
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
micromark-util-chunked@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-1.0.0.tgz#5b40d83f3d53b84c4c6bce30ed4257e9a4c79d06"
integrity sha512-5e8xTis5tEZKgesfbQMKRCyzvffRRUX+lK/y+DvsMFdabAicPkkZV6gO+FEWi9RfuKKoxxPwNL+dFF0SMImc1g==
dependencies:
micromark-util-symbol "^1.0.0"
micromark-util-classify-character@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-1.0.0.tgz#cbd7b447cb79ee6997dd274a46fc4eb806460a20"
integrity sha512-F8oW2KKrQRb3vS5ud5HIqBVkCqQi224Nm55o5wYLzY/9PwHGXC01tr3d7+TqHHz6zrKQ72Okwtvm/xQm6OVNZA==
dependencies:
micromark-util-character "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
micromark-util-combine-extensions@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.0.0.tgz#91418e1e74fb893e3628b8d496085639124ff3d5"
integrity sha512-J8H058vFBdo/6+AsjHp2NF7AJ02SZtWaVUjsayNFeAiydTxUwViQPxN0Hf8dp4FmCQi0UUFovFsEyRSUmFH3MA==
dependencies:
micromark-util-chunked "^1.0.0"
micromark-util-types "^1.0.0"
micromark-util-decode-numeric-character-reference@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.0.0.tgz#dcc85f13b5bd93ff8d2868c3dba28039d490b946"
integrity sha512-OzO9AI5VUtrTD7KSdagf4MWgHMtET17Ua1fIpXTpuhclCqD8egFWo85GxSGvxgkGS74bEahvtM0WP0HjvV0e4w==
dependencies:
micromark-util-symbol "^1.0.0"
micromark-util-decode-string@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-1.0.2.tgz#942252ab7a76dec2dbf089cc32505ee2bc3acf02"
integrity sha512-DLT5Ho02qr6QWVNYbRZ3RYOSSWWFuH3tJexd3dgN1odEuPNxCngTCXJum7+ViRAd9BbdxCvMToPOD/IvVhzG6Q==
dependencies:
decode-named-character-reference "^1.0.0"
micromark-util-character "^1.0.0"
micromark-util-decode-numeric-character-reference "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-encode@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-1.0.1.tgz#2c1c22d3800870ad770ece5686ebca5920353383"
integrity sha512-U2s5YdnAYexjKDel31SVMPbfi+eF8y1U4pfiRW/Y8EFVCy/vgxk/2wWTxzcqE71LHtCuCzlBDRU2a5CQ5j+mQA==
micromark-util-html-tag-name@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.0.0.tgz#75737e92fef50af0c6212bd309bc5cb8dbd489ed"
integrity sha512-NenEKIshW2ZI/ERv9HtFNsrn3llSPZtY337LID/24WeLqMzeZhBEE6BQ0vS2ZBjshm5n40chKtJ3qjAbVV8S0g==
micromark-util-normalize-identifier@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.0.0.tgz#4a3539cb8db954bbec5203952bfe8cedadae7828"
integrity sha512-yg+zrL14bBTFrQ7n35CmByWUTFsgst5JhA4gJYoty4Dqzj4Z4Fr/DHekSS5aLfH9bdlfnSvKAWsAgJhIbogyBg==
dependencies:
micromark-util-symbol "^1.0.0"
micromark-util-resolve-all@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-1.0.0.tgz#a7c363f49a0162e931960c44f3127ab58f031d88"
integrity sha512-CB/AGk98u50k42kvgaMM94wzBqozSzDDaonKU7P7jwQIuH2RU0TeBqGYJz2WY1UdihhjweivStrJ2JdkdEmcfw==
dependencies:
micromark-util-types "^1.0.0"
micromark-util-sanitize-uri@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.0.0.tgz#27dc875397cd15102274c6c6da5585d34d4f12b2"
integrity sha512-cCxvBKlmac4rxCGx6ejlIviRaMKZc0fWm5HdCHEeDWRSkn44l6NdYVRyU+0nT1XC72EQJMZV8IPHF+jTr56lAg==
dependencies:
micromark-util-character "^1.0.0"
micromark-util-encode "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-subtokenize@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-1.0.2.tgz#ff6f1af6ac836f8bfdbf9b02f40431760ad89105"
integrity sha512-d90uqCnXp/cy4G881Ub4psE57Sf8YD0pim9QdjCRNjfas2M1u6Lbt+XZK9gnHL2XFhnozZiEdCa9CNfXSfQ6xA==
dependencies:
micromark-util-chunked "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.0"
uvu "^0.5.0"
micromark-util-symbol@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-1.0.1.tgz#b90344db62042ce454f351cf0bebcc0a6da4920e"
integrity sha512-oKDEMK2u5qqAptasDAwWDXq0tG9AssVwAx3E9bBF3t/shRIGsWIRG+cGafs2p/SnDSOecnt6hZPCE2o6lHfFmQ==
micromark-util-types@^1.0.0, micromark-util-types@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-1.0.2.tgz#f4220fdb319205812f99c40f8c87a9be83eded20"
integrity sha512-DCfg/T8fcrhrRKTPjRrw/5LLvdGV7BHySf/1LOZx7TzWZdYRjogNtyNq885z3nNallwr3QUKARjqvHqX1/7t+w==
micromark@^3.0.0:
version "3.0.10"
resolved "https://registry.yarnpkg.com/micromark/-/micromark-3.0.10.tgz#1eac156f0399d42736458a14b0ca2d86190b457c"
integrity sha512-ryTDy6UUunOXy2HPjelppgJ2sNfcPz1pLlMdA6Rz9jPzhLikWXv/irpWV/I2jd68Uhmny7hHxAlAhk4+vWggpg==
dependencies:
"@types/debug" "^4.0.0"
debug "^4.0.0"
decode-named-character-reference "^1.0.0"
micromark-core-commonmark "^1.0.1"
micromark-factory-space "^1.0.0"
micromark-util-character "^1.0.0"
micromark-util-chunked "^1.0.0"
micromark-util-combine-extensions "^1.0.0"
micromark-util-decode-numeric-character-reference "^1.0.0"
micromark-util-encode "^1.0.0"
micromark-util-normalize-identifier "^1.0.0"
micromark-util-resolve-all "^1.0.0"
micromark-util-sanitize-uri "^1.0.0"
micromark-util-subtokenize "^1.0.0"
micromark-util-symbol "^1.0.0"
micromark-util-types "^1.0.1"
uvu "^0.5.0"
micromatch@^3.1.10, micromatch@^3.1.4: micromatch@^3.1.10, micromatch@^3.1.4:
version "3.1.10" version "3.1.10"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
@ -7418,6 +7024,14 @@ mimic-fn@^2.0.0, mimic-fn@^2.1.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
mini-create-react-context@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz#df60501c83151db69e28eac0ef08b4002efab040"
integrity sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA==
dependencies:
"@babel/runtime" "^7.5.5"
tiny-warning "^1.0.3"
mini-css-extract-plugin@0.9.0: mini-css-extract-plugin@0.9.0:
version "0.9.0" version "0.9.0"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz#47f2cf07aa165ab35733b1fc97d4c46c0564339e" resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz#47f2cf07aa165ab35733b1fc97d4c46c0564339e"
@ -7534,11 +7148,6 @@ move-concurrently@^1.0.1:
rimraf "^2.5.4" rimraf "^2.5.4"
run-queue "^1.0.3" run-queue "^1.0.3"
mri@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==
ms@2.0.0: ms@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@ -7549,7 +7158,7 @@ ms@2.1.1:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
ms@2.1.2, ms@^2.1.1: ms@^2.1.1:
version "2.1.2" version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
@ -8214,6 +7823,13 @@ path-to-regexp@0.1.7:
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
path-to-regexp@^1.7.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
dependencies:
isarray "0.0.1"
path-type@^2.0.0: path-type@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73"
@ -9104,20 +8720,6 @@ prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, pr
object-assign "^4.1.1" object-assign "^4.1.1"
react-is "^16.8.1" react-is "^16.8.1"
prop-types@^15.0.0:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.13.1"
property-information@^6.0.0:
version "6.1.1"
resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.1.1.tgz#5ca85510a3019726cb9afed4197b7b8ac5926a22"
integrity sha512-hrzC564QIl0r0vy4l6MvRLhafmUowhO/O3KgVSoXIbbA2Sz4j8HGpJc6T2cubRVwMwpdiG/vKGfhT4IixmKN9w==
proxy-addr@~2.0.5: proxy-addr@~2.0.5:
version "2.0.6" version "2.0.6"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf"
@ -9433,16 +9035,11 @@ react-helmet@^6.0.0:
react-fast-compare "^3.1.1" react-fast-compare "^3.1.1"
react-side-effect "^2.1.0" react-side-effect "^2.1.0"
react-is@^16.12.0, react-is@^16.13.1, react-is@^16.6.3, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6: react-is@^16.12.0, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.6.3, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-is@^18.0.0:
version "18.1.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67"
integrity sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==
react-joyride@^2.4.0: react-joyride@^2.4.0:
version "2.4.0" version "2.4.0"
resolved "https://registry.yarnpkg.com/react-joyride/-/react-joyride-2.4.0.tgz#273a99fea4804a48155e7cc7bae308dcbc8cb725" resolved "https://registry.yarnpkg.com/react-joyride/-/react-joyride-2.4.0.tgz#273a99fea4804a48155e7cc7bae308dcbc8cb725"
@ -9464,27 +9061,6 @@ react-lifecycles-compat@^3.0.4:
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-markdown@^8.0.3:
version "8.0.3"
resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-8.0.3.tgz#e8aba0d2f5a1b2124d476ee1fff9448a2f57e4b3"
integrity sha512-We36SfqaKoVNpN1QqsZwWSv/OZt5J15LNgTLWynwAN5b265hrQrsjMtlRNwUvS+YyR3yDM8HpTNc4pK9H/Gc0A==
dependencies:
"@types/hast" "^2.0.0"
"@types/prop-types" "^15.0.0"
"@types/unist" "^2.0.0"
comma-separated-tokens "^2.0.0"
hast-util-whitespace "^2.0.0"
prop-types "^15.0.0"
property-information "^6.0.0"
react-is "^18.0.0"
remark-parse "^10.0.0"
remark-rehype "^10.0.0"
space-separated-tokens "^2.0.0"
style-to-object "^0.3.0"
unified "^10.0.0"
unist-util-visit "^4.0.0"
vfile "^5.0.0"
react-popper@^1.3.4: react-popper@^1.3.4:
version "1.3.7" version "1.3.7"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.7.tgz#f6a3471362ef1f0d10a4963673789de1baca2324" resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.7.tgz#f6a3471362ef1f0d10a4963673789de1baca2324"
@ -9503,32 +9079,45 @@ react-proptype-conditional-require@^1.0.4:
resolved "https://registry.yarnpkg.com/react-proptype-conditional-require/-/react-proptype-conditional-require-1.0.4.tgz#69c2d5741e6df5e08f230f36bbc2944ee1222555" resolved "https://registry.yarnpkg.com/react-proptype-conditional-require/-/react-proptype-conditional-require-1.0.4.tgz#69c2d5741e6df5e08f230f36bbc2944ee1222555"
integrity sha1-acLVdB5t9eCPIw82u8KUTuEiJVU= integrity sha1-acLVdB5t9eCPIw82u8KUTuEiJVU=
react-redux@^8.0.1: react-redux@^7.2.0:
version "8.0.1" version "7.2.1"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.0.1.tgz#2bc029f5ada9b443107914c373a2750f6bc0f40c" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.1.tgz#8dedf784901014db2feca1ab633864dee68ad985"
integrity sha512-LMZMsPY4DYdZfLJgd7i79n5Kps5N9XVLCJJeWAaPYTV+Eah2zTuBjTxKtNEbjiyitbq80/eIkm55CYSLqAub3w== integrity sha512-T+VfD/bvgGTUA74iW9d2i5THrDQWbweXP0AVNI8tNd1Rk5ch1rnMiJkDD67ejw7YBKM4+REvcvqRuWJb7BLuEg==
dependencies: dependencies:
"@babel/runtime" "^7.12.1" "@babel/runtime" "^7.5.5"
"@types/hoist-non-react-statics" "^3.3.1" hoist-non-react-statics "^3.3.0"
"@types/use-sync-external-store" "^0.0.3" loose-envify "^1.4.0"
hoist-non-react-statics "^3.3.2" prop-types "^15.7.2"
react-is "^18.0.0" react-is "^16.9.0"
use-sync-external-store "^1.0.0"
react-router-dom@^6.3.0: react-router-dom@^5.1.2:
version "6.3.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662"
integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw== integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==
dependencies: dependencies:
history "^5.2.0" "@babel/runtime" "^7.1.2"
react-router "6.3.0" history "^4.9.0"
loose-envify "^1.3.1"
prop-types "^15.6.2"
react-router "5.2.0"
tiny-invariant "^1.0.2"
tiny-warning "^1.0.0"
react-router@6.3.0: react-router@5.2.0:
version "6.3.0" version "5.2.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293"
integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==
dependencies: dependencies:
history "^5.2.0" "@babel/runtime" "^7.1.2"
history "^4.9.0"
hoist-non-react-statics "^3.1.0"
loose-envify "^1.3.1"
mini-create-react-context "^0.4.0"
path-to-regexp "^1.7.0"
prop-types "^15.6.2"
react-is "^16.6.0"
tiny-invariant "^1.0.2"
tiny-warning "^1.0.0"
react-scripts@3.4.1: react-scripts@3.4.1:
version "3.4.1" version "3.4.1"
@ -9806,25 +9395,6 @@ relateurl@^0.2.7:
resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
remark-parse@^10.0.0:
version "10.0.1"
resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-10.0.1.tgz#6f60ae53edbf0cf38ea223fe643db64d112e0775"
integrity sha512-1fUyHr2jLsVOkhbvPRBJ5zTKZZyD6yZzYaWCS6BPBdQ8vEMBCH+9zNCDA6tET/zHCi/jLqjCWtlJZUPk+DbnFw==
dependencies:
"@types/mdast" "^3.0.0"
mdast-util-from-markdown "^1.0.0"
unified "^10.0.0"
remark-rehype@^10.0.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-10.1.0.tgz#32dc99d2034c27ecaf2e0150d22a6dcccd9a6279"
integrity sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==
dependencies:
"@types/hast" "^2.0.0"
"@types/mdast" "^3.0.0"
mdast-util-to-hast "^12.1.0"
unified "^10.0.0"
remove-trailing-separator@^1.0.1: remove-trailing-separator@^1.0.1:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
@ -10062,13 +9632,6 @@ rxjs@^6.5.3, rxjs@^6.6.0:
dependencies: dependencies:
tslib "^1.9.0" tslib "^1.9.0"
sade@^1.7.3:
version "1.8.1"
resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"
integrity sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==
dependencies:
mri "^1.1.0"
safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.2" version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@ -10522,11 +10085,6 @@ source-map@^0.5.0, source-map@^0.5.6:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
space-separated-tokens@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.1.tgz#43193cec4fb858a2ce934b7f98b7f2c18107098b"
integrity sha512-ekwEbFp5aqSPKaqeY1PGrlGQxPNaq+Cnx4+bE2D8sciBQrHpbwoBbawqTN2+6jPs9IdWxxiUcN0K2pkczD3zmw==
spdx-correct@^3.0.0: spdx-correct@^3.0.0:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
@ -10844,13 +10402,6 @@ style-loader@0.23.1:
loader-utils "^1.1.0" loader-utils "^1.1.0"
schema-utils "^1.0.0" schema-utils "^1.0.0"
style-to-object@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.3.0.tgz#b1b790d205991cc783801967214979ee19a76e46"
integrity sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==
dependencies:
inline-style-parser "0.1.1"
styled-components@^5.1.0: styled-components@^5.1.0:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.1.1.tgz#96dfb02a8025794960863b9e8e365e3b6be5518d" resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.1.1.tgz#96dfb02a8025794960863b9e8e365e3b6be5518d"
@ -11045,7 +10596,7 @@ tiny-invariant@^1.0.2:
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875"
integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==
tiny-warning@^1.0.0: tiny-warning@^1.0.0, tiny-warning@^1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
@ -11140,11 +10691,6 @@ tree-changes@^0.9.0:
"@gilbarbara/deep-equal" "^0.1.0" "@gilbarbara/deep-equal" "^0.1.0"
is-lite "^0.8.1" is-lite "^0.8.1"
trough@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/trough/-/trough-2.1.0.tgz#0f7b511a4fde65a46f18477ab38849b22c554876"
integrity sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==
ts-pnp@1.1.6: ts-pnp@1.1.6:
version "1.1.6" version "1.1.6"
resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.1.6.tgz#389a24396d425a0d3162e96d2b4638900fdc289a" resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.1.6.tgz#389a24396d425a0d3162e96d2b4638900fdc289a"
@ -11257,19 +10803,6 @@ unicode-property-aliases-ecmascript@^1.0.4:
resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4" resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4"
integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg== integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==
unified@^10.0.0:
version "10.1.2"
resolved "https://registry.yarnpkg.com/unified/-/unified-10.1.2.tgz#b1d64e55dafe1f0b98bb6c719881103ecf6c86df"
integrity sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==
dependencies:
"@types/unist" "^2.0.0"
bail "^2.0.0"
extend "^3.0.0"
is-buffer "^2.0.0"
is-plain-obj "^4.0.0"
trough "^2.0.0"
vfile "^5.0.0"
union-value@^1.0.0: union-value@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
@ -11304,71 +10837,6 @@ unique-slug@^2.0.0:
dependencies: dependencies:
imurmurhash "^0.1.4" imurmurhash "^0.1.4"
unist-builder@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/unist-builder/-/unist-builder-3.0.0.tgz#728baca4767c0e784e1e64bb44b5a5a753021a04"
integrity sha512-GFxmfEAa0vi9i5sd0R2kcrI9ks0r82NasRq5QHh2ysGngrc6GiqD5CDf1FjPenY4vApmFASBIIlk/jj5J5YbmQ==
dependencies:
"@types/unist" "^2.0.0"
unist-util-generated@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/unist-util-generated/-/unist-util-generated-2.0.0.tgz#86fafb77eb6ce9bfa6b663c3f5ad4f8e56a60113"
integrity sha512-TiWE6DVtVe7Ye2QxOVW9kqybs6cZexNwTwSMVgkfjEReqy/xwGpAXb99OxktoWwmL+Z+Epb0Dn8/GNDYP1wnUw==
unist-util-is@^5.0.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-5.1.1.tgz#e8aece0b102fa9bc097b0fef8f870c496d4a6236"
integrity sha512-F5CZ68eYzuSvJjGhCLPL3cYx45IxkqXSetCcRgUXtbcm50X2L9oOWQlfUfDdAf+6Pd27YDblBfdtmsThXmwpbQ==
unist-util-position@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-4.0.3.tgz#5290547b014f6222dff95c48d5c3c13a88fadd07"
integrity sha512-p/5EMGIa1qwbXjA+QgcBXaPWjSnZfQ2Sc3yBEEfgPwsEmJd8Qh+DSk3LGnmOM4S1bY2C0AjmMnB8RuEYxpPwXQ==
dependencies:
"@types/unist" "^2.0.0"
unist-util-stringify-position@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-3.0.2.tgz#5c6aa07c90b1deffd9153be170dce628a869a447"
integrity sha512-7A6eiDCs9UtjcwZOcCpM4aPII3bAAGv13E96IkawkOAW0OhH+yRxtY0lzo8KiHpzEMfH7Q+FizUmwp8Iqy5EWg==
dependencies:
"@types/unist" "^2.0.0"
unist-util-visit-parents@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-4.1.1.tgz#e83559a4ad7e6048a46b1bdb22614f2f3f4724f2"
integrity sha512-1xAFJXAKpnnJl8G7K5KgU7FY55y3GcLIXqkzUj5QF/QVP7biUm0K0O2oqVkYsdjzJKifYeWn9+o6piAK2hGSHw==
dependencies:
"@types/unist" "^2.0.0"
unist-util-is "^5.0.0"
unist-util-visit-parents@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-5.1.0.tgz#44bbc5d25f2411e7dfc5cecff12de43296aa8521"
integrity sha512-y+QVLcY5eR/YVpqDsLf/xh9R3Q2Y4HxkZTp7ViLDU6WtJCEcPmRzW1gpdWDCDIqIlhuPDXOgttqPlykrHYDekg==
dependencies:
"@types/unist" "^2.0.0"
unist-util-is "^5.0.0"
unist-util-visit@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-3.1.0.tgz#9420d285e1aee938c7d9acbafc8e160186dbaf7b"
integrity sha512-Szoh+R/Ll68QWAyQyZZpQzZQm2UPbxibDvaY8Xc9SUtYgPsDzx5AWSk++UUt2hJuow8mvwR+rG+LQLw+KsuAKA==
dependencies:
"@types/unist" "^2.0.0"
unist-util-is "^5.0.0"
unist-util-visit-parents "^4.0.0"
unist-util-visit@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-4.1.0.tgz#f41e407a9e94da31594e6b1c9811c51ab0b3d8f5"
integrity sha512-n7lyhFKJfVZ9MnKtqbsqkQEk5P1KShj0+//V7mAcoI6bpbUjh3C/OG8HVD+pBihfh6Ovl01m8dkcv9HNqYajmQ==
dependencies:
"@types/unist" "^2.0.0"
unist-util-is "^5.0.0"
unist-util-visit-parents "^5.0.0"
universalify@^0.1.0: universalify@^0.1.0:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
@ -11434,11 +10902,6 @@ url@^0.11.0:
punycode "1.3.2" punycode "1.3.2"
querystring "0.2.0" querystring "0.2.0"
use-sync-external-store@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz#3343c3fe7f7e404db70f8c687adf5c1652d34e82"
integrity sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==
use@^3.1.0: use@^3.1.0:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
@ -11496,16 +10959,6 @@ uuid@^3.0.1, uuid@^3.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uvu@^0.5.0:
version "0.5.3"
resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.3.tgz#3d83c5bc1230f153451877bfc7f4aea2392219ae"
integrity sha512-brFwqA3FXzilmtnIyJ+CxdkInkY/i4ErvP7uV0DnUVxQcQ55reuHphorpF+tZoVHK2MniZ/VJzI7zJQoc9T9Yw==
dependencies:
dequal "^2.0.0"
diff "^5.0.0"
kleur "^4.0.3"
sade "^1.7.3"
v8-compile-cache@^2.0.3: v8-compile-cache@^2.0.3:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745"
@ -11543,24 +10996,6 @@ verror@1.10.0:
core-util-is "1.0.2" core-util-is "1.0.2"
extsprintf "^1.2.0" extsprintf "^1.2.0"
vfile-message@^3.0.0:
version "3.1.2"
resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-3.1.2.tgz#a2908f64d9e557315ec9d7ea3a910f658ac05f7d"
integrity sha512-QjSNP6Yxzyycd4SVOtmKKyTsSvClqBPJcd00Z0zuPj3hOIjg0rUPG6DbFGPvUKRgYyaIWLPKpuEclcuvb3H8qA==
dependencies:
"@types/unist" "^2.0.0"
unist-util-stringify-position "^3.0.0"
vfile@^5.0.0:
version "5.3.2"
resolved "https://registry.yarnpkg.com/vfile/-/vfile-5.3.2.tgz#b499fbc50197ea50ad3749e9b60beb16ca5b7c54"
integrity sha512-w0PLIugRY3Crkgw89TeMvHCzqCs/zpreR31hl4D92y6SOE07+bfJe+dK5Q2akwS+i/c801kzjoOr9gMcTe6IAA==
dependencies:
"@types/unist" "^2.0.0"
is-buffer "^2.0.0"
unist-util-stringify-position "^3.0.0"
vfile-message "^3.0.0"
vm-browserify@^1.0.1: vm-browserify@^1.0.1:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"