Compare commits

...

25 Commits

Author SHA1 Message Date
adc4f163b7 Resolved lint issues 2022-05-08 09:20:36 +00:00
4b0014ba83 Fix infinite repaint loop in DraftPreview 2022-05-08 09:17:41 +00:00
e1e49b06da Removed all class components 2022-05-08 09:03:21 +00:00
9fcd193a87 Functional component for usersearch 2022-05-08 08:49:51 +00:00
fb5559158f Fixed bug on project settings page 2022-05-08 08:29:50 +00:00
e4bdadba6e Addressed issue with wideBody determination on project page 2022-05-08 08:18:27 +00:00
080d604a17 Additional functional requirements and fixed lint issues 2022-05-07 15:55:37 +00:00
a31dca4006 Resolved lint errors 2022-05-07 15:47:59 +00:00
2a3330e38d More functional components 2022-05-07 15:44:38 +00:00
80ce9beef5 Warp as functional component 2022-05-07 15:33:25 +00:00
4161397d56 Made DraftPreview into a functional component 2022-05-07 15:17:59 +00:00
ce81d99cdf Changed Tieups into functional component 2022-05-07 14:40:34 +00:00
ce32c85b64 Moved Weft to use functional component 2022-05-07 09:21:57 +00:00
8f8e799323 Resolved all lint issues 2022-05-06 22:53:40 +00:00
c9a5593fe0 Moved all routing to App.js 2022-05-06 22:49:00 +00:00
0575d795ea Moved group nav to App.js 2022-05-06 22:39:23 +00:00
5220c1a484 Moving all routing into App.js 2022-05-06 22:28:37 +00:00
99e4a01ef2 Modernised additional components 2022-05-06 21:43:52 +00:00
8b482d6761 Removed un-needed files. Modernised additional components 2022-05-06 21:33:59 +00:00
9fcb902ee2 More components updated 2022-05-06 20:28:22 +00:00
cb67d7afe0 Additional updates 2022-05-06 20:03:55 +00:00
e100a8520c Updated react-redux 2022-05-06 19:09:25 +00:00
c2fbd272fd Fixed lint issues 2022-05-06 18:48:54 +00:00
5970422d58 Removed all usage of withRouter 2022-05-06 18:22:53 +00:00
1124e0af52 Initial changes 2022-05-06 18:13:43 +00:00
49 changed files with 2680 additions and 2840 deletions

View File

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

View File

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

View File

@ -1,55 +1,79 @@
import React, { useEffect } from 'react';
import { Switch, Route, Link, withRouter } from 'react-router-dom';
import { Routes, Route, Link } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { ToastContainer, toast } from 'react-toastify';
import { Grid, Divider, Icon, Container } from 'semantic-ui-react';
import api from 'api';
import actions from 'actions';
import utils from 'utils/utils.js';
import NavBar from 'components/includes/NavBar';
import logo from 'images/logo/main.png';
import MarketingHome from './marketing/Home.js';
import MarketingPricing from './marketing/Pricing.js';
import PrivacyPolicy from './marketing/PrivacyPolicy';
import TermsOfUse from './marketing/TermsOfUse';
import Login from './Login.js';
import ForgottenPassword from './ForgottenPassword';
import ResetPassword from './ResetPassword';
import Home from './main/Home.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 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 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 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 Docs from './docs';
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 }) {
function App() {
const dispatch = useDispatch();
const { isAuthenticated, isAuthenticating, isAuthenticatingType, user, driftReady, syncedToDrift } = useSelector(state => {
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;
useEffect(() => {
api.auth.autoLogin(onLoginSuccess);
}, [onLoginSuccess]);
api.auth.autoLogin(token => dispatch(actions.auth.receiveLogin(token)));
}, [dispatch]);
useEffect(() => {
if (!loggedInUserId) return;
api.users.getMyProjects(onReceiveProjects);
api.groups.getMine(onReceiveGroups);
api.users.getMyProjects(p => dispatch(actions.projects.receiveProjects(p)));
api.groups.getMine(g => dispatch(actions.groups.receiveGroups(g)));
api.invitations.get(({ invitations, sentInvitations}) => {
onReceiveInvitations(invitations.concat(sentInvitations));
dispatch(actions.invitations.receiveInvitations(invitations.concat(sentInvitations)));
});
}, [loggedInUserId, onReceiveProjects, onReceiveGroups, onReceiveInvitations]);
}, [dispatch, loggedInUserId]);
useEffect(() => {
window.drift && window.drift.on('ready', () => {
onDriftReady();
dispatch(actions.users.initDrift());
});
}, [onDriftReady]);
}, [dispatch]);
useEffect(() => {
if (user && driftReady && !syncedToDrift && window.drift) {
@ -58,35 +82,52 @@ function App({ user, groups, syncedToDrift, driftReady, onOpenRegister, onCloseA
username: user.username,
createdAt: user.createdAt,
});
onDriftSynced();
dispatch(actions.users.syncDrift(null));
}
}, [user, driftReady, syncedToDrift, onDriftSynced]);
}, [dispatch, user, driftReady, syncedToDrift]);
return (
<div style={{display: 'flex', flexDirection: 'column', minHeight: '100vh'}}>
<Helmet defaultTitle={'Treadl'} titleTemplate={`%s | Treadl`} />
<NavBar />
<div style={{ flex: '1 0 0' }}>
<Switch>
<Route exact path="/" render={props => (isAuthenticated
? <Home {...props} />
: <MarketingHome {...props} onRegisterClicked={onOpenRegister} />)
<Routes>
<Route end path="/" element={isAuthenticated
? <Home />
: <MarketingHome onRegisterClicked={() => dispatch(actions.auth.openRegister())} />
} />
<Route path="/pricing" render={props => <MarketingPricing {...props} onRegisterClicked={onOpenRegister} />} />
<Route path="/privacy" component={PrivacyPolicy} />
<Route path="/terms-of-use" component={TermsOfUse} />
<Route path="/password/forgotten" component={ForgottenPassword} />
<Route path="/password/reset" component={ResetPassword} />
<Route path="/settings" component={Settings} />
<Route path="/projects/new" component={NewProject} />
<Route path="/groups/new" component={NewGroup} />
<Route path="/groups/:id" component={Group} />
<Route path='/root' component={Root} />
<Route path="/:username/edit" component={Profile} />
<Route path="/:username/:projectPath" component={Project} />
<Route path="/:username" component={Profile} />
</Switch>
<Login open={isAuthenticating} authType={isAuthenticatingType} onClose={onCloseAuthentication} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/terms-of-use" element={<TermsOfUse />} />
<Route path="/password/forgotten" element={<ForgottenPassword />} />
<Route path="/password/reset" element={<ResetPassword />} />
<Route path="/settings" element={<Settings />}>
<Route path='identity' element={<SettingsIdentity />} />
<Route path='notifications' element={<SettingsNotification />} />
<Route path='account' element={<SettingsAccount />} />
<Route path='' element={<SettingsIdentity />} />
</Route>
<Route path="/projects/new" element={<NewProject />} />
<Route path="/groups/new" element={<NewGroup />} />
<Route path="/groups/:id" element={<Group />}>
<Route path='feed' element={<GroupFeed />} />
<Route path='members' element={<GroupMembers />} />
<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/>
<Divider hidden section />
</div>
@ -131,34 +172,4 @@ function App({ user, groups, syncedToDrift, driftReady, onOpenRegister, onCloseA
);
}
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;
export default App;

View File

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

View File

@ -1,193 +1,175 @@
import React, { Component } from 'react';
import React, { useState } from 'react';
import {
Message, Modal, Grid, Form, Input, Button,
} from 'semantic-ui-react';
import { connect } from 'react-redux';
import { Link, withRouter } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import actions from 'actions';
import { api } from 'api';
import ReadingImage from 'images/reading.png';
class Login extends Component {
constructor(props) {
super(props);
this.state = {
registering: false, username: '', email: '', password: '', loading: false,
};
}
function Login({ open, authType, onClose }) {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const dispatch = useDispatch();
login = () => {
const { email, password } = this.state;
this.setState({ loading: true });
api.auth.login(email, password, this.props.onLoginStart, (data) => {
this.setState({ loading: false, password: '', email: '', username: '' });
this.props.onLoginSuccess(data);
this.props.onClose();
const { error } = useSelector(state => {
const { loading, error } = state.auth;
return { loading, error };
});
const login = () => {
setLoading(true);
api.auth.login(email, password, () => dispatch(actions.auth.requestLogin()), (data) => {
setLoading(false);
setPassword('');
setEmail('');
setUsername('');
dispatch(actions.auth.receiveLogin(data));
onClose();
}, (err) => {
this.props.onLoginFailure(err);
this.setState({ loading: false });
dispatch(actions.auth.loginError(err));
setLoading(false);
});
}
};
register = () => {
const { username, email, password } = this.state;
this.setState({ loading: true });
api.auth.register(username, email, password, this.props.onLoginStart, (data) => {
this.setState({ loading: false, password: '', email: '', username: '' });
this.props.onLoginSuccess(data);
this.props.onClose();
const register = () => {
setLoading(true);
api.auth.register(username, email, password, () => dispatch(actions.auth.requestLogin()), (data) => {
setLoading(false);
setPassword('');
setEmail('');
setUsername('');
dispatch(actions.auth.receiveLogin(data));
onClose();
}, (err) => {
this.props.onLoginFailure(err);
this.setState({ loading: false });
dispatch(actions.auth.loginError(err));
setLoading(false);
});
}
};
handleChange = (event) => {
const update = {};
update[event.target.name] = event.target.value;
this.setState(update);
}
return (
<div>
{authType === 'register'
&& (
<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>
render() {
const { loading } = this.state;
return (
<div>
{this.props.authType === 'register'
&& (
<Modal dimmer="inverted" open={this.props.open} onClose={this.props.onClose}>
<Modal.Header>
<span role="img" aria-label="wave">👋</span> Welcome!
<Button floated="right" onClick={this.props.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>)}
<Form onSubmit={register}>
<Form.Field>
<label>Pick a username
<small> (you can use letters, numbers and underscores)</small>
</label>
<Input autoFocus size="large" fluid name="username" type="text" value={username} onChange={e => setUsername(e.target.value)} />
</Form.Field>
<Form.Field>
<label>Email address
<small> (for password resets &amp; other important things)</small>
</label>
<Input size="large" fluid name="email" type="email" value={email} onChange={e => setEmail(e.target.value)} />
</Form.Field>
<Form.Field>
<label>Choose a strong password
<small> (at least 6 characters)</small>
</label>
<Input size="large" fluid name="password" type="password" value={password} onChange={e => setPassword(e.target.value)} />
</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>
<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>
)
}
{authType === 'login'
&& (
<Modal dimmer="inverted" open={open} onClose={onClose}>
<Modal.Header>
<span role="img" aria-label="Peace"> </span>
Welcome back <Button floated="right" onClick={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>
{this.props.error && (<div className="ui warning message">{this.props.error.message}</div>)}
<Form onSubmit={this.register}>
<Form.Field>
<label>Pick a username
<small> (you can use letters, numbers and underscores)</small>
</label>
<Input autoFocus size="large" fluid name="username" type="text" value={this.state.username} onChange={event => this.handleChange(event)} />
</Form.Field>
<Form.Field>
<label>Email address
<small> (for password resets &amp; other important things)</small>
</label>
<Input size="large" fluid name="email" type="email" value={this.state.email} onChange={event => this.handleChange(event)} />
</Form.Field>
<Form.Field>
<label>Choose a strong password
<small> (at least 6 characters)</small>
</label>
<Input size="large" fluid name="password" type="password" value={this.state.password} onChange={event => this.handleChange(event)} />
</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>
<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>
);
}
<Grid.Column computer={8}>
{error && (<div className="ui warning message">{error.message}</div>)}
<Form onSubmit={login}>
<Form.Field>
<label>Your email address or username</label>
<Input autoFocus size="large" fluid name="email" type="text" value={email} onChange={e => setEmail(e.target.value)} placeholder='Email or username' />
</Form.Field>
<Form.Field>
<label>Password
<Link to="/password/forgotten" style={{ float: 'right' }} onClick={onClose}>Forgotten your password?</Link>
</label>
<Input size="large" fluid name="password" type="password" value={password} onChange={e => setPassword(e.target.value)} 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>
);
}
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;
export default Login;

View File

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

View File

@ -0,0 +1,29 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { Loader, Divider, Button, Message, Container, Segment, Grid, Card, Icon, List } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { connect } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { toast } from 'react-toastify';
import actions from 'actions';
import api from 'api';
@ -13,29 +13,37 @@ import HelpLink from 'components/includes/HelpLink';
import ProjectCard from 'components/includes/ProjectCard';
import Tour from 'components/includes/Tour';
function Home({ user, groups, projects, invitations, loadingProjects, onReceiveProjects, onReceiveInvitations, onDismissInvitation, onReceiveGroup, onJoinGroup }) {
function Home() {
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(() => {
api.invitations.get(({ invitations, sentInvitations}) => {
onReceiveInvitations(invitations.concat(sentInvitations));
dispatch(actions.invitations.receiveInvitations(invitations.concat(sentInvitations)));
});
}, [onReceiveInvitations]);
}, [dispatch]);
useEffect(() => {
api.users.getMyProjects(onReceiveProjects);
api.users.getMyProjects(p => dispatch(actions.projects.receiveProjects(p)));
setTimeout(() =>
setRunJoyride(true), 2000);
}, [onReceiveProjects]);
}, [dispatch]);
const declineInvite = (invite) => {
api.invitations.decline(invite._id, () => onDismissInvitation(invite._id), err => toast.error(err.message));
api.invitations.decline(invite._id, () => dispatch(actions.invitations.dismiss(invite._id)), err => toast.error(err.message));
}
const acceptInvite = (invite) => {
api.invitations.accept(invite._id, (result) => {
onDismissInvitation(invite._id);
dispatch(actions.invitations.dismiss(invite._id));
if (result.group) {
onReceiveGroup(result.group);
onJoinGroup(user._id, result.group._id);
dispatch(actions.groups.receiveGroup(result.group));
dispatch(actions.users.joinGroup(user._id, result.group._id));
}
}, err => toast.error(err.message));
}
@ -170,23 +178,4 @@ function Home({ user, groups, projects, invitations, loadingProjects, onReceiveP
);
}
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;
export default Home;

View File

@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import { Loader, Button, Segment } from 'semantic-ui-react';
import { withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { useParams } from 'react-router-dom';
import utils from 'utils/utils.js';
import actions from 'actions';
import api from 'api';
@ -10,16 +10,29 @@ import FeedMessage from 'components/includes/FeedMessage';
import NewFeedMessage from 'components/includes/NewFeedMessage';
import MessagesImage from 'images/messages.png';
function Feed({ user, group, entries, onReceiveEntry, onDeleteEntry, newEntry, onJoinGroup, replyingTo, updateReplyingTo, loadingEntries, updateLoadingEntries, match }) {
function Feed() {
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 || [];
useEffect(() => {
updateLoadingEntries(true);
api.groups.getEntries(match.params.id, entries => {
updateLoadingEntries(false);
entries.forEach(e => onReceiveEntry(e));
dispatch(actions.groups.updateLoadingEntries(true));
api.groups.getEntries(id, entries => {
dispatch(actions.groups.updateLoadingEntries(false));
entries.forEach(e => dispatch(actions.groups.receiveEntry(e)));
});
}, [match.params.id, myGroups.length, onReceiveEntry, updateLoadingEntries]);
}, [dispatch, id, myGroups.length]);
const mainEntries = entries && entries.filter(e => !e.inReplyTo);
@ -27,9 +40,9 @@ function Feed({ user, group, entries, onReceiveEntry, onDeleteEntry, newEntry, o
<div>
{utils.isInGroup(user, group._id) && <>
{replyingTo ?
<Button style={{marginBottom: 20}} color='teal' content='Write a new post' onClick={() => updateReplyingTo(null)} />
<Button style={{marginBottom: 20}} color='teal' content='Write a new post' onClick={() => dispatch(actions.posts.updateReplyingTo(null))} />
:
<NewFeedMessage user={user} group={group} forType='group' onPosted={onReceiveEntry}/>
<NewFeedMessage user={user} group={group} forType='group' onPosted={e => dispatch(actions.groups.receiveEntry(e))}/>
}
{loadingEntries && !mainEntries?.length &&
<div style={{textAlign:'center'}}>
@ -45,35 +58,11 @@ function Feed({ user, group, entries, onReceiveEntry, onDeleteEntry, newEntry, o
</Segment>
}
{mainEntries?.map(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} />
<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))} />
)}
</>}
</div>
)
}
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;
export default Feed;

View File

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

View File

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

View File

@ -2,23 +2,30 @@ import React from 'react';
import { Helmet } from 'react-helmet';
import { Icon, Form, Grid, Input, Checkbox, Button, Divider } from 'semantic-ui-react';
import { toast } from 'react-toastify';
import { withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import actions from 'actions';
import api from 'api';
import HelpLink from 'components/includes/HelpLink';
function NewGroup({ user, newGroupName, newGroupDescription, newGroupClosed, onUpdateGroupName, onUpdateGroupDescription, onUpdateGroupClosed, onReceiveGroup, loading, onUpdateGroupLoading, history }) {
function NewGroup() {
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 = () => {
onUpdateGroupLoading(true);
dispatch(actions.groups.request(true));
api.groups.create({ name: newGroupName, description: newGroupDescription, closed: newGroupClosed }, (group) => {
onReceiveGroup(group);
onUpdateGroupLoading(false);
history.push(`/groups/${group._id}/members`);
dispatch(actions.groups.receiveGroup(group));
dispatch(actions.groups.request(false));
navigate(`/groups/${group._id}/members`);
}, (err) => {
onUpdateGroupLoading(false);
dispatch(actions.groups.request(false));
toast.error(err.message);
});
}
@ -38,12 +45,12 @@ function NewGroup({ user, newGroupName, newGroupDescription, newGroupClosed, onU
<h3>About your group</h3>
<p>Give your group a short name. You can change this whenever you like.</p>
<Input autoFocus type="text" fluid onChange={e => onUpdateGroupName(e.target.value)} value={newGroupName} />
<Input autoFocus type="text" fluid onChange={e => dispatch(actions.groups.updateNewGroupName(e.target.value))} value={newGroupName} />
<Divider hidden />
<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 => onUpdateGroupDescription(e.target.value)} /></Form>
<Form><Form.TextArea placeholder="Group description (optional)..." value={newGroupDescription} onChange={e => dispatch(actions.groups.updateNewGroupDescription(e.target.value))} /></Form>
<Checkbox style={{marginTop: 40}} toggle checked={newGroupClosed} label="Make this group a closed group" onChange={(e,c) => onUpdateGroupClosed(c.checked)} />
<Checkbox style={{marginTop: 40}} toggle checked={newGroupClosed} label="Make this group a closed group" onChange={(e,c) => dispatch(actions.groups.updateNewGroupClosed(c.checked))} />
<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>
</div>
@ -52,7 +59,7 @@ function NewGroup({ user, newGroupName, newGroupDescription, newGroupClosed, onU
<p>You can add and invite others to join your group after you've created it.</p>
<div style={{textAlign: 'right'}}>
<Button basic onClick={history.goBack}>Cancel</Button>
<Button basic onClick={() => navigate(-1)}>Cancel</Button>
<Button color="teal" icon="check" content="Create group" onClick={createGroup} loading={loading} />
</div>
</Grid.Column>
@ -60,19 +67,4 @@ function NewGroup({ user, newGroupName, newGroupDescription, newGroupClosed, onU
);
}
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;
export default NewGroup;

View File

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

View File

@ -1,31 +1,40 @@
import React from 'react';
import { Header, Button, Divider, Segment, Form, } from 'semantic-ui-react';
import { withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { toast } from 'react-toastify';
import utils from 'utils/utils.js';
import actions from 'actions';
import api from 'api';
function Settings({ user, group, loading, onUpdateGroupLoading, onUpdateGroup, onDeleteGroup, history }) {
function Settings() {
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 = () => {
onUpdateGroupLoading(true);
dispatch(actions.groups.request(true));
const { _id, name, description, closed } = group;
api.groups.update(_id, { name, description, closed }, () => {
onUpdateGroupLoading(false);
dispatch(actions.groups.request(false));
toast.info('Group updated');
}, err => {
toast.error(err.message);
onUpdateGroupLoading(false);
dispatch(actions.groups.request(false));
});
}
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(() => {
api.groups.delete(group._id, () => {
toast.info('Group deleted');
onDeleteGroup(group._id);
history.push(`/`);
dispatch(actions.groups.deleteGroup(group._id));
navigate(`/`);
}, err => toast.error(err.message));
}, () => {});
}
@ -35,9 +44,9 @@ function Settings({ user, group, loading, onUpdateGroupLoading, onUpdateGroup, o
<Segment color='blue'>
<Header>About this group</Header>
<Form>
<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 => onUpdateGroup(group._id, { description: e.target.value })}/>
<Form.Checkbox toggle checked={group.closed} label="Closed group" onChange={(e,c) => onUpdateGroup(group._id, { closed: c.checked })} />
<Form.Input label='Group name' value={group.name} onChange={e => dispatch(actions.groups.updateGroup(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.Checkbox toggle checked={group.closed} label="Closed group" onChange={(e,c) => dispatch(actions.groups.updateGroup(group._id, { closed: c.checked }))} />
<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>
</div>
@ -56,17 +65,4 @@ function Settings({ user, group, loading, onUpdateGroupLoading, onUpdateGroup, o
)
}
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;
export default Settings;

View File

@ -1,9 +1,9 @@
import React, { Component } from 'react';
import React, { useState } from 'react';
import { Helmet } from 'react-helmet';
import { Icon, Form, Message, Grid, Input, Button, Divider } from 'semantic-ui-react';
import { toast } from 'react-toastify';
import { withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import utils from 'utils/utils.js';
import actions from 'actions';
import api from 'api';
@ -11,113 +11,103 @@ import api from 'api';
import UserChip from 'components/includes/UserChip';
import HelpLink from 'components/includes/HelpLink';
class NewProject extends Component {
constructor(props) {
super(props);
this.state = {
name: 'My new project', description: '', visibility: 'public', openSource: true, groupVisibility: [], error: '', loading: false,
};
}
function NewProject() {
const [name, setName] = useState('My new project');
const [description, setDescription] = useState('');
const [visibility, setVisibility] = useState('public');
const [openSource, setOpenSource] = useState(true);
const [groupVisibility, setGroupVisibility] = useState([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const dispatch = useDispatch();
updateName = (event) => {
this.setState({ name: event.target.value });
}
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 };
});
changeVisibility = (event, r) => {
this.setState({ visibility: r.checked ? 'private' : 'public' });
}
const updateName = (event) => {
setName(event.target.value);
};
changeOpenSource = (event, c) => {
this.setState({ openSource: c.checked });
}
const changeVisibility = (event, r) => {
setVisibility(r.checked ? 'private' : 'public');
};
createProject = () => {
this.setState({ loading: true });
const { name, description, visibility, openSource, groupVisibility } = this.state;
const changeOpenSource = (event, c) => {
setOpenSource(c.checked);
};
const createProject = () => {
setLoading(true);
api.projects.create({ name, description, visibility, openSource, groupVisibility }, (project) => {
this.props.onReceiveProject(project);
this.setState({ loading: false });
this.props.history.push(`/${this.props.user.username}/${project.path}`);
dispatch(actions.projects.receiveProject(project));
setLoading(false);
navigate(`/${user.username}/${project.path}`);
}, (err) => {
this.setState({ loading: false });
setLoading(false);
toast.error(err.message);
setError(err.message);
});
}
};
render() {
const {
name, description, visibility, openSource, groupVisibility
} = this.state;
const { user, groups } = this.props;
return (
<Grid stackable centered>
<Helmet title={'Create Project'} />
<Grid.Column computer={8}>
<h2 style={{ marginTop: 40 }}>
<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
</h2>
<p>This will contain all of the files and patterns for your project.</p>
</h2>
<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>
<p>Give your project a short name. You can always rename it later on.</p>
<Input autoFocus type="text" fluid onChange={this.updateName} value={name} label={<UserChip user={user} style={{ marginRight: 10, marginTop: 4 }} />} />
<h3>About your project</h3>
<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 }} />} />
<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 />
<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 />
<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 />
{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>
{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>
<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>
<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>
<Divider section />
<Divider section />
{this.state.error && <Message color="orange" content={this.state.error} />}
<div style={{textAlign:'right'}}>
<Button basic onClick={this.props.history.goBack}>Cancel</Button>
<Button color="teal" icon="check" content="Create project" onClick={this.createProject} loading={this.state.loading} />
</div>
</Grid.Column>
</Grid>
);
}
{error && <Message color="orange" content={error} />}
<div style={{textAlign:'right'}}>
<Button basic onClick={() => navigate(-1)}>Cancel</Button>
<Button color="teal" icon="check" content="Create project" onClick={createProject} loading={loading} />
</div>
</Grid.Column>
</Grid>
);
}
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;
export default NewProject;

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Segment, Label, Input, Icon, Card, Loader } from 'semantic-ui-react';
import { Link, withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { Link, useParams } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import styled from 'styled-components';
import utils from 'utils/utils.js';
import actions from 'actions';
@ -22,17 +22,31 @@ const CompactObject = styled(Link)`
}
`;
function ObjectList({ user, objects, currentObject, project, fullProjectPath, onReceiveObjects, compact }) {
function ObjectList({ compact }) {
const [loading, setLoading] = useState(false);
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(() => {
setLoading(true);
api.projects.getObjects(fullProjectPath, projects => {
onReceiveObjects(projects);
api.projects.getObjects(fullProjectPath, o => {
dispatch(actions.objects.receiveMultiple(o));
setLoading(false);
}, err => setLoading(false));
}, [fullProjectPath, onReceiveObjects]);
}, [fullProjectPath, dispatch]);
let filteredObjects = objects;
if (objectFilter) {
@ -157,26 +171,4 @@ function ObjectList({ user, objects, currentObject, project, fullProjectPath, on
);
}
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;
export default ObjectList;

View File

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

View File

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

View File

@ -1,134 +1,122 @@
import React, { Component } from 'react';
import React, { useState } from 'react';
import { Form, Input, Divider, Segment, Button, Icon } from 'semantic-ui-react';
import { withRouter } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import { connect } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import utils from 'utils/utils.js';
import actions from 'actions';
import api from 'api';
import HelpLink from 'components/includes/HelpLink';
class ProjectSettings extends Component {
constructor(props) {
super(props);
this.state = { name: props.project.name, visibility: props.project.visibility || 'public', openSource: props.project.openSource, groupVisibility: props.project.groupVisibility || [] };
}
changeVisibility = (event, r) => {
this.setState({ visibility: r.checked ? 'private' : 'public' });
}
function ProjectSettings() {
const { username, projectPath } = useParams();
const { groups, project, fullProjectPath } = 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];
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();
changeOpenSource = (event, c) => {
this.setState({ openSource: c.checked });
}
const changeVisibility = (event, r) => {
setVisibility(r.checked ? 'private' : 'public');
};
saveName = () => {
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(this.props.fullProjectPath, { name: this.state.name }, (project) => {
this.props.onReceiveProject(project);
this.props.history.push(`/${project.owner.username}/${project.path}`);
api.projects.update(fullProjectPath, { name: name }, (project) => {
dispatch(actions.projects.receiveProject(project));
navigate(`/${project.owner.username}/${project.path}`);
}, err => toast.error(err.message));
}, () => {});
}
});
};
saveVisibility = () => {
api.projects.update(this.props.project.fullName, { visibility: this.state.visibility, openSource: this.state.openSource, groupVisibility: this.state.groupVisibility }, (project) => {
this.props.onReceiveProject(project);
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));
}
};
deleteProject = () => {
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(this.props.fullProjectPath, () => {
this.props.onDeleteProject(this.props.project._id);
api.projects.delete(fullProjectPath, () => {
dispatch(actions.projects.deleteProject(project._id));
toast.info('🗑️ Project deleted');
this.props.history.push('/');
this.setState({ nameError: '' });
navigate('/');
}, err => toast.error(err.message));
}, () => {});
});
}
render() {
const { name, visibility, openSource } = this.state;
const { groups } = this.props;
return (
<div>
<h2>Project settings</h2>
return (
<div>
<h2>Project settings</h2>
<HelpLink link={`${process.env.REACT_APP_SUPPORT_ROOT}Projects#changing-the-project-s-settings`} />
<HelpLink link={`${process.env.REACT_APP_SUPPORT_ROOT}Projects#changing-the-project-s-settings`} />
<Divider hidden section />
<Divider hidden section />
<Segment>
<h3>General settings</h3>
<Segment>
<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>
<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={saveName} />
onChange={e => setName(e.target.value)}
/>
</Segment>
<Divider hidden />
<Divider hidden />
<Segment color="yellow">
<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 />
{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>
<Form.Select multiple
label='Make this project always visible to members of these groups'
value={this.state.groupVisibility}
options={groups.map(g => ({ key: g._id, value: g._id, text: g.name }))}
onChange={(e, s) => this.setState({ groupVisibility: s.value })}
/>
</>
}
</Form>
<Segment color="yellow">
<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 />
<Button color="teal" content="Save visibility" onClick={this.saveVisibility} />
</Segment>
{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>
<Divider hidden 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) => setGroupVisibility(s.value)}
/>
</>
}
</Form>
<Divider hidden />
<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>
);
}
<Button color="teal" content="Save visibility" onClick={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={deleteProject} />
</Segment>
</div>
);
}
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;
export default ProjectSettings;

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { withRouter, Prompt } from 'react-router-dom';
import { connect } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import styled from 'styled-components';
import ElementPan from 'components/includes/ElementPan';
@ -25,104 +25,91 @@ export const StyledPattern = styled.div`
margin:20px;
`;
class Draft extends Component {
constructor(props) {
super(props);
this.state = { unsaved: false, saving: false };
}
function Draft() {
const [unsaved, setUnsaved] = useState(false);
const [saving, setSaving] = useState(false);
const [object, setObject] = useState();
const [pattern, setPattern] = useState();
const [name] = useState();
const { objectId } = useParams();
const dispatch = useDispatch();
const { editor } = useSelector(state => ({ editor: state.objects.editor }));
componentDidMount() {
api.objects.get(this.props.match.params.objectId, (object) => {
if (!object.pattern.baseSize) object.pattern.baseSize = 10;
this.setState(object);
useEffect(() => {
api.objects.get(objectId, (o) => {
if (!o.pattern.baseSize) o.pattern.baseSize = 10;
setObject(o);
setPattern(o.pattern);
});
}
}, [objectId]);
updateObject = (update) => {
this.setState(Object.assign({}, this.state, update));
this.setState({ unsaved: true });
}
const updateObject = (update) => {
setObject(Object.assign({}, object, update));
setUnsaved(true);
};
updatePattern = (update) => {
const newPattern = Object.assign({}, this.state.pattern, update);
this.setState(Object.assign({}, this.state, { pattern: newPattern }));
this.setState({ unsaved: true });
}
const updatePattern = (update) => {
const newPattern = Object.assign({}, pattern, update);
setPattern(Object.assign({}, pattern, newPattern));
setUnsaved(true);
};
saveObject = () => {
this.setState({ saving: true });
const saveObject = () => {
setSaving(true);
const canvas = document.getElementsByClassName('drawdown')[0];
const object = Object.assign({}, this.state);
object.preview = canvas.toDataURL();
api.objects.update(this.props.match.params.objectId, object, (o) => {
const newObject = Object.assign({}, object);
newObject.preview = canvas.toDataURL();
api.objects.update(objectId, newObject, (o) => {
toast.success('Pattern saved');
this.props.onReceiveObject(o);
this.setState({ unsaved: false, saving: false });
dispatch(actions.objects.receive(o));
setUnsaved(false);
setSaving(false);
}, (err) => {
toast.error(err.message);
this.setState({ saving: false });
setSaving(false);
});
}
};
rerunTour = () => {
if (!pattern) return null;
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'}}>
}
render() {
if (!this.state.pattern) return null;
const { unsaved, saving } = this.state;
const { warp, weft, tieups, baseSize } = this.state.pattern;
const cellStyle = { width: `${baseSize || 10}px`, height: `${baseSize || 10}px` };
return (
<div>
<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}
<div style={{flex: 1, overflow: 'hidden'}}>
<ElementPan
disabled={!(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`
}}
>
<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={this.updatePattern} />
<Weft cellStyle={cellStyle} warp={warp} weft={weft} baseSize={baseSize} updatePattern={this.updatePattern} />
<Tieups cellStyle={cellStyle} warp={warp} weft={weft} tieups={tieups} updatePattern={this.updatePattern} 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>
<Warp baseSize={baseSize} cellStyle={cellStyle} warp={warp} weft={weft} updatePattern={updatePattern} />
<Weft cellStyle={cellStyle} warp={warp} weft={weft} baseSize={baseSize} updatePattern={updatePattern} />
<Tieups cellStyle={cellStyle} warp={warp} weft={weft} tieups={tieups} updatePattern={updatePattern} 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={object} pattern={pattern} updateObject={updateObject} updatePattern={updatePattern} saveObject={saveObject} baseSize={baseSize} unsaved={unsaved} saving={saving}/>
</div>
</div>
);
}
</div>
);
}
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;
export default Draft;

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import React, { Component } from 'react';
import React, { useState } from 'react';
import {
Confirm, Select, Segment, Accordion, Grid, Icon, Input, Button,
} from 'semantic-ui-react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import Slider from 'rc-slider';
import styled from 'styled-components';
@ -35,69 +35,78 @@ const ColourSquare = styled.div`
}
`;
class Tools extends Component {
state = { colours: [], activeDrawers: ['properties', 'drawing', 'palette'], view: 'interlacement' }
function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updatePattern, updateObject, saveObject }) {
const [activeDrawers, setActiveDrawers] = useState(['properties', 'drawing', 'palette']);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const navigate = useNavigate();
const dispatch = useDispatch();
const { objectId, username, projectPath } = useParams();
enableTool = (tool) => {
this.props.onEditorUpdated({ tool, colour: this.props.editor.colour });
}
const { project, editor } = useSelector(state => {
let project = {};
state.projects.projects.forEach((p) => {
if (p.path === projectPath && p.owner && p.owner.username === username) project = p;
});
return { project, editor: state.objects.editor };
});
setColour = (colour) => {
this.props.onEditorUpdated({ tool: 'colour', colour });
}
const enableTool = (tool) => {
dispatch(actions.objects.updateEditor({ tool, colour: editor.colour }));
};
setView = (view) => {
this.props.onEditorUpdated({ view });
}
const setColour = (colour) => {
dispatch(actions.objects.updateEditor({ tool: 'colour', colour }));
};
setName = (event) => {
this.props.updateObject({ name: event.target.value });
}
const setEditorView = (view) => {
dispatch(actions.objects.updateEditor({ view }));
};
setShafts = (event) => {
const warp = { ...this.props.warp, shafts: parseInt(event.target.value, 10) || 1 };
this.props.updatePattern({ warp });
}
const setName = (event) => {
updateObject({ name: event.target.value });
};
setTreadles = (event) => {
const weft = { ...this.props.weft, treadles: parseInt(event.target.value, 10) || 1 };
this.props.updatePattern({ weft });
}
const setShafts = (event) => {
updatePattern({ warp: { ...warp, shafts: parseInt(event.target.value, 10) || 1 } });
};
onZoomChange = zoom => this.props.updatePattern({ baseSize: zoom || 10 })
const setTreadles = (event) => {
updatePattern({ weft: { ...weft, treadles: parseInt(event.target.value, 10) || 1 } });
};
drawerIsActive = drawer => this.state.activeDrawers.indexOf(drawer) > -1
const onZoomChange = zoom => updatePattern({ baseSize: zoom || 10 });
activateDrawer = (drawer) => {
const index = this.state.activeDrawers.indexOf(drawer);
const drawers = this.state.activeDrawers;
const drawerIsActive = drawer => activeDrawers.indexOf(drawer) > -1;
const activateDrawer = (drawer) => {
const index = activeDrawers.indexOf(drawer);
const drawers = activeDrawers;
if (index === -1) {
drawers.push(drawer);
} else {
drawers.splice(index, 1);
}
this.setState({ activeDrawers: drawers });
}
setActiveDrawers(drawers);
};
deleteObject = () => {
api.objects.delete(this.props.match.params.objectId, () => {
const deleteObject = () => {
api.objects.delete(objectId, () => {
toast('🗑️ Pattern deleted');
this.props.onObjectDeleted(this.props.match.params.objectId);
this.props.history.push(`/${this.props.project.fullName}`);
dispatch(actions.objects.delete(objectId));
navigate(`/${project.fullName}`);
}, err => console.log(err));
}
revertChanges = () => {
const 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.')
if (sure) {
window.location.reload();
}
}
};
changeWidth = () => {
const 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.'));
if (!newWidth) return;
const { warp } = this.props;
if (newWidth > warp.threading.length) {
let i = warp.threading.length;
while (i < newWidth) {
@ -110,14 +119,13 @@ class Tools extends Component {
warp.threading.splice(newWidth);
warp.threads = warp.threading.length;
}
this.props.updatePattern({ warp });
this.props.onEditorUpdated();
this.props.onEditorUpdated({ tool: 'pan' });
}
changeHeight = () => {
updatePattern({ warp });
dispatch(actions.objects.updateEditor());
dispatch(actions.objects.updateEditor( { tool: 'pan' }));
};
const 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.'));
if (!newHeight) return;
const { weft } = this.props;
if (newHeight > weft.treadling.length) {
let i = weft.treadling.length;
while (i < newHeight) {
@ -130,134 +138,115 @@ class Tools extends Component {
weft.treadling.splice(newHeight);
weft.threads = weft.treadling.length;
}
this.props.updatePattern({ weft });
this.props.onEditorUpdated();
}
updatePattern({ weft });
dispatch(actions.objects.updateEditor());
};
render() {
const { warp, weft, editor, unsaved, saving } = this.props;
return (
<div className="pattern-toolbox joyride-tools">
{unsaved &&
<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>
return (
<div className="pattern-toolbox joyride-tools">
{unsaved &&
<Segment attached="top">
<Button fluid color="teal" icon="save" content="Save pattern" onClick={() => saveObject(/*this.refs.canvas*/)} loading={saving}/>
<Button style={{marginTop: 5}} fluid icon='refresh' content='Undo changes' onClick={revertChanges} />
</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>
);
}
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;
export default Tools;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,214 +0,0 @@
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,56 +1,44 @@
import React, { Component } from 'react';
import React, { useState } from 'react';
import { Message, Input, Segment, Button } from 'semantic-ui-react';
import { withRouter } from 'react-router-dom';
import { toast } from 'react-toastify';
import { connect } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import actions from 'actions';
import api from 'api';
class IdentitySettings extends Component {
constructor(props) {
super(props);
this.state = { newUsername: '' };
}
function IdentitySettings() {
const [newUsername, setNewUsername] = useState('');
const dispatch = useDispatch();
const { user } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user };
});
updateUsername = () => {
api.users.update(this.props.user.username, { username: this.state.newUsername }, (user) => {
this.props.onUpdateUsername(this.props.user._id, this.state.newUsername);
const updateUsername = () => {
api.users.update(user.username, { username: newUsername }, (user) => {
dispatch(actions.users.updateUsername(user._id, newUsername));
toast.info('Username updated');
this.setState({ newUsername: '' });
setNewUsername('');
}, err => toast.error(err.message));
}
};
render() {
return (
<Segment raised color="blue">
<h3>Username</h3>
<p>Change the username you use for your profile and to login to Treadl.</p>
return (
<Segment raised color="blue">
<h3>Username</h3>
<p>Change the username you use for your profile and to login to Treadl.</p>
<Message>
<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>If you change your username, your old one will become available to be taken by somebody else.</p>
</Message>
<Input
type="text" value={this.state.newUsername}
onChange={e => this.setState({ newUsername: e.target.value })}
action=<Button color="yellow" content="Set new username" onClick={this.updateUsername} />
/>
</Segment>
);
}
<Message>
<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>If you change your username, your old one will become available to be taken by somebody else.</p>
</Message>
<Input
type="text" value={newUsername}
onChange={e => setNewUsername(e.target.value)}
action=<Button color="yellow" content="Set new username" onClick={updateUsername} />
/>
</Segment>
);
}
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;
export default IdentitySettings;

View File

@ -1,9 +1,7 @@
import React, { Component } from 'react';
import React from 'react';
import { Label, Table, Checkbox, Divider, Segment } from 'semantic-ui-react';
import { withRouter } from 'react-router-dom';
import { toast } from 'react-toastify';
import { connect } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import actions from 'actions';
import api from 'api';
import utils from 'utils/utils.js';
@ -16,68 +14,58 @@ const subs = [
{ key: 'projects.commented', text: 'Someone comments on one of your projects'},
];
class NotificationSettings extends Component {
function NotificationSettings() {
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 };
});
hasEmailSub = (key) => utils.hasSubscription(this.props.user, key);
toggleEmailSub = (key, enable) => {
const { user, onSubsUpdated } = this.props;
const hasEmailSub = (key) => utils.hasSubscription(user, key);
const toggleEmailSub = (key, enable) => {
if (enable)
api.users.createEmailSubscription(user.username, key, ({ subscriptions }) => onSubsUpdated(user._id, subscriptions), err => toast.error(err.message));
api.users.createEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message));
else
api.users.deleteEmailSubscription(user.username, key, ({ subscriptions }) => onSubsUpdated(user._id, subscriptions), err => toast.error(err.message));
}
api.users.deleteEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message));
};
render() {
const { groups } = this.props;
return (
<Segment raised color="blue">
<h3>Email preferences</h3>
<p>Customise which automated emails you'd like to receive from Treadl.</p>
<Divider hidden />
return (
<Segment raised color="blue">
<h3>Email preferences</h3>
<p>Customise which automated emails you'd like to receive from Treadl.</p>
<Divider hidden />
<Table fluid basic='very'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Notification type</Table.HeaderCell>
<Table.HeaderCell collapsing>Enabled</Table.HeaderCell>
<Table fluid basic='very'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Notification type</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.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>
)}
{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) => toggleEmailSub(`groupFeed-${g._id}`, c.checked)} checked={hasEmailSub(`groupFeed-${g._id}`)}/></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) => 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>
);
}
)}
</Table.Body>
</Table>
</Segment>
);
}
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;
export default NotificationSettings;

View File

@ -1,43 +1,27 @@
import React, { Component } from 'react';
import {
Divider, Container, Grid, Menu,
} from 'semantic-ui-react';
import { Link, Switch, Route } from 'react-router-dom';
import React from 'react';
import { Divider, Container, Grid, Menu } from 'semantic-ui-react';
import { Outlet, NavLink } from 'react-router-dom';
import Identity from './Identity';
import Notification from './Notification';
import Billing from './Billing';
import Account from './Account';
function Settings() {
return (
<Container style={{ marginTop: '40px' }}>
<h2>Manage your account</h2>
<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>
class Settings extends Component {
render() {
return (
<Container style={{ marginTop: '40px' }}>
<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>
);
}
<Grid.Column stretched width={12}>
<Outlet />
</Grid.Column>
</Grid>
</Container>
);
}
export default Settings;

View File

@ -1,133 +1,123 @@
import React, { Component } from 'react';
import React from 'react';
import {
Icon, Segment, Form, Button, Divider,
} from 'semantic-ui-react';
import { toast } from 'react-toastify';
import { Link } from 'react-router-dom';
import { connect } from 'react-redux';
import { Link, useParams } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import utils from 'utils/utils';
import actions from 'actions';
import api from 'api';
import FileChooser from 'components/includes/FileChooser';
class EditProfile extends Component {
updatePicture = (avatar) => {
api.users.update(this.props.profileUser.username, { avatar }, this.props.onReceiveUser);
}
function EditProfile() {
const { username } = useParams();
const dispatch = useDispatch();
const { profileUser } = useSelector(state => {
const users = state.users.users;
const profileUser = users.filter(u => username === u.username)[0];
return { profileUser };
});
updateProfile = () => {
const { bio, location, website } = this.props.profileUser;
api.users.update(this.props.profileUser.username, { bio, location, website }, (user) => {
this.props.onReceiveUser(user);
const updatePicture = (avatar) => {
api.users.update(profileUser.username, { avatar }, u => dispatch(actions.users.receive(u)));
};
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');
}, err => toast.error(err.message));
}
};
updateSocial = () => {
const { twitter, facebook, linkedIn, instagram } = this.props.profileUser;
api.users.update(this.props.profileUser.username, { twitter, facebook, linkedIn, instagram }, (user) => {
this.props.onReceiveUser(user);
const updateSocial = () => {
const { twitter, facebook, linkedIn, instagram } = profileUser;
api.users.update(profileUser.username, { twitter, facebook, linkedIn, instagram }, (u) => {
dispatch(actions.users.receive(u));
toast.info('Profile saved');
}, err => toast.error(err.message));
}
};
render() {
const { profileUser, onUserEdited } = this.props;
return (
<div>
<Link to={`/${profileUser.username}`} className="ui basic button">
<Icon name="arrow left" /> Back to profile
</Link>
<h3>Edit public profile</h3>
return (
<div>
<Link to={`/${profileUser.username}`} className="ui basic button">
<Icon name="arrow left" /> Back to profile
</Link>
<h3>Edit public profile</h3>
<Divider hidden />
<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 />
<Segment raised color="blue">
<h3>Essentials</h3>
<h5>Profile picture</h5>
<FileChooser
forType="user" for={profileUser}
trigger=<Button basic color="yellow" icon="image" content="Choose an image" />
accept="image/*" onComplete={f => this.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 => this.updatePicture(a.key)}
/>
))}
<Divider hidden />
<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>
<Form.TextArea
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="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>
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>
</div>);
}
<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 => dispatch(actions.users.update(profileUser._id, { twitter: e.target.value }))}
/>
<Form.Input
label="Facebook" placeholder="username" icon="facebook" iconPosition="left"
value={profileUser.facebook}
onChange={e => dispatch(actions.users.update(profileUser._id, { facebook: e.target.value }))}
/>
<Form.Input
label="Instagram" placeholder="username" icon="instagram" iconPosition="left"
value={profileUser.instagram}
onChange={e => dispatch(actions.users.update(profileUser._id, { instagram: e.target.value }))}
/>
<Form.Input
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>);
}
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;
export default EditProfile;

View File

@ -1,198 +1,174 @@
import React, { Component } from 'react';
import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { Loader, Icon, List, Container, Card, Grid, Message } from 'semantic-ui-react';
import { Link, Switch, Route } from 'react-router-dom';
import { connect } from 'react-redux';
import { Link, Outlet, useParams } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import moment from 'moment';
import utils from 'utils/utils';
import actions from 'actions';
import api from 'api';
import EditProfile from './EditProfile';
import ProfileProjects from './ProfileProjects';
import BlurrableImage from 'components/includes/BlurrableImage';
class Profile extends Component {
constructor(props) {
super(props);
this.state = { loading: false };
}
componentDidMount() {
this.setState({ loading: true });
api.users.get(this.props.match.params.username, user => {
this.props.onReceiveUser(user);
this.setState({ loading: false });
function Profile() {
const [loading, setLoading] = useState(false);
const dispatch = useDispatch();
const { username } = useParams();
const { user, profileUser, errorMessage } = useSelector(state => {
const users = state.users.users;
const profileUser = users.filter(u => username === u.username)[0];
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user, profileUser, errorMessage: state.users.errorMessage };
});
useEffect(() => {
setLoading(true);
api.users.get(username, user => {
dispatch(actions.users.receive(user));
setLoading(false);
}, err => {
this.props.onRequestFailed(err);
this.setState({ loading: false });
dispatch(actions.users.requestFailed(err));
setLoading(false);
});
}
}, [dispatch, username])
componentDidUpdate(prevProps) {
if (this.props.match.params.username !== prevProps.match.params.username) {
api.users.get(this.props.match.params.username, this.props.onReceiveUser);
}
}
render() {
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>
<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>
return (
<Container style={{ marginTop: '40px' }}>
<Helmet title={profileUser?.username ? `${profileUser.username}'s Profile` : 'Profile'} />
{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>
{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) &&
<Card fluid>
<Card.Content>
<Card.Header>
About {profileUser.username}
</Card.Header>
</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>
{user && user._id === profileUser._id
&& (
<Card.Content extra textAlign="right">
<Link to={`/${profileUser.username}/edit`}>
<Icon name="pencil" /> Edit profile
</Link>
</Card.Content>
)
}
</Card>
<Grid.Column computer={11}>
<Switch>
<Route path="/:username/edit" component={EditProfile} />
<Route component={ProfileProjects} />
</Switch>
</Grid.Column>
</Grid>
)
}
</Container>
);
}
{(profileUser.bio || profileUser.website || profileUser.twitter || profileUser.facebook) &&
<Card fluid>
<Card.Content>
<Card.Header>
About {profileUser.username}
</Card.Header>
</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}>
<Outlet />
</Grid.Column>
</Grid>
)
}
</Container>
);
}
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;
export default Profile;

View File

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

View File

@ -1,93 +0,0 @@
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,78 +1,76 @@
import React, { Component } from 'react';
import React from 'react';
import { Helmet } from 'react-helmet';
import { Container } from 'semantic-ui-react';
class PrivacyPolicy extends Component {
render() {
return (
<Container style={{ marginTop: 50, marginBottom: 50 }}>
<Helmet title='Privacy Policy' />
<h1>Privacy Policy</h1>
<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>
<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>
function PrivacyPolicy() {
return (
<Container style={{ marginTop: 50, marginBottom: 50 }}>
<Helmet title='Privacy Policy' />
<h1>Privacy Policy</h1>
<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>
<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>
<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>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>
<h2>What data does Treadl collect?</h2>
<h2>What data does Treadl collect?</h2>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>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>
<ul>
<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>Simple Analytics (for aggregated visitor analytics). This service may collect information about your browser and the location you visit us from.</li>
</ul>
<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>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>
<ul>
<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>Simple Analytics (for aggregated visitor analytics). This service may collect information about your browser and the location you visit us from.</li>
</ul>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
</Container>
);
}
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
</Container>
);
}
export default PrivacyPolicy;

View File

@ -1,44 +1,42 @@
import React, { Component } from 'react';
import React from 'react';
import { Helmet } from 'react-helmet';
import { Container } from 'semantic-ui-react';
class TermsOfUse extends Component {
render() {
return (
<Container style={{ marginTop: 50, marginBottom: 50 }}>
<Helmet title='Terms of Use' />
<h1>Treadl Terms of Use</h1>
<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>
function TermsOfUse() {
return (
<Container style={{ marginTop: 50, marginBottom: 50 }}>
<Helmet title='Terms of Use' />
<h1>Treadl Terms of Use</h1>
<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>
<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>
<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>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>
<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>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 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>
<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>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 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>
</Container>
);
}
</Container>
);
}
export default TermsOfUse;

View File

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

View File

@ -1110,13 +1110,20 @@
dependencies:
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.5.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.7.2", "@babel/runtime@^7.8.4":
version "7.11.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
dependencies:
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":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278"
@ -1629,6 +1636,13 @@
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
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":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
@ -1642,6 +1656,21 @@
"@types/minimatch" "*"
"@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":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762"
@ -1667,11 +1696,28 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd"
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@*":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
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@*":
version "14.6.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.0.tgz#7d4411bf5157339337d7cff864d9ff45f177b499"
@ -1682,16 +1728,45 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
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":
version "1.5.4"
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24"
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":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
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@*":
version "15.0.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d"
@ -2454,6 +2529,11 @@ babylon@^6.18.0:
resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
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:
version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
@ -2911,6 +2991,11 @@ chalk@^4.1.0:
ansi-styles "^4.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:
version "0.7.0"
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
@ -3136,6 +3221,11 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
dependencies:
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:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
@ -3659,6 +3749,11 @@ cssstyle@^1.0.0, cssstyle@^1.1.1:
dependencies:
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:
version "1.0.1"
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
@ -3707,6 +3802,13 @@ debug@^3.1.1, debug@^3.2.5:
dependencies:
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:
version "4.1.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
@ -3719,6 +3821,13 @@ decamelize@^1.2.0:
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
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:
version "0.2.0"
resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
@ -3811,6 +3920,11 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
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:
version "1.0.1"
resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843"
@ -3847,6 +3961,11 @@ diff-sequences@^24.9.0:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5"
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:
version "5.0.3"
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"
@ -4594,7 +4713,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
assign-symbols "^1.0.0"
is-extendable "^1.0.1"
extend@~3.0.2:
extend@^3.0.0, extend@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
@ -5229,6 +5348,11 @@ hash.js@^1.0.0, hash.js@^1.0.3:
inherits "^2.0.3"
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:
version "1.2.0"
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
@ -5239,7 +5363,7 @@ hex-color-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"
integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==
history@^4.7.2, history@^4.9.0:
history@^4.7.2:
version "4.10.1"
resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3"
integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==
@ -5251,6 +5375,13 @@ history@^4.7.2, history@^4.9.0:
tiny-warning "^1.0.0"
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:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@ -5260,7 +5391,7 @@ hmac-drbg@^1.0.0:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
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:
hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^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"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
@ -5568,6 +5699,11 @@ ini@^1.3.5:
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
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:
version "7.0.4"
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.4.tgz#99af5bde47153abca23f5c7fc30db247f39da703"
@ -5708,6 +5844,11 @@ is-buffer@^1.0.2, is-buffer@^1.1.5:
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
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:
version "1.2.0"
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb"
@ -5883,6 +6024,11 @@ is-plain-obj@^1.0.0:
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
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:
version "2.0.4"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
@ -5958,11 +6104,6 @@ is-wsl@^2.1.1:
dependencies:
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:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@ -6646,6 +6787,11 @@ kleur@^3.0.3:
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
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:
version "3.0.0"
resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555"
@ -6820,7 +6966,7 @@ loglevel@^1.6.6:
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171"
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.3.1, loose-envify@^1.4.0:
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@ -6896,6 +7042,54 @@ md5.js@^1.3.4:
inherits "^2.0.1"
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:
version "2.0.4"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b"
@ -6906,6 +7100,11 @@ mdn-data@2.0.6:
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978"
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:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@ -6970,6 +7169,201 @@ microevent.ts@~0.1.1:
resolved "https://registry.yarnpkg.com/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0"
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:
version "3.1.10"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
@ -7024,14 +7418,6 @@ mimic-fn@^2.0.0, mimic-fn@^2.1.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
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:
version "0.9.0"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz#47f2cf07aa165ab35733b1fc97d4c46c0564339e"
@ -7148,6 +7534,11 @@ move-concurrently@^1.0.1:
rimraf "^2.5.4"
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:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@ -7158,7 +7549,7 @@ ms@2.1.1:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
ms@^2.1.1:
ms@2.1.2, ms@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
@ -7823,13 +8214,6 @@ path-to-regexp@0.1.7:
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
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:
version "2.0.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73"
@ -8720,6 +9104,20 @@ 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"
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:
version "2.0.6"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf"
@ -9035,11 +9433,16 @@ react-helmet@^6.0.0:
react-fast-compare "^3.1.1"
react-side-effect "^2.1.0"
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:
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:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
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:
version "2.4.0"
resolved "https://registry.yarnpkg.com/react-joyride/-/react-joyride-2.4.0.tgz#273a99fea4804a48155e7cc7bae308dcbc8cb725"
@ -9061,6 +9464,27 @@ react-lifecycles-compat@^3.0.4:
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
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:
version "1.3.7"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.7.tgz#f6a3471362ef1f0d10a4963673789de1baca2324"
@ -9079,45 +9503,32 @@ 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"
integrity sha1-acLVdB5t9eCPIw82u8KUTuEiJVU=
react-redux@^7.2.0:
version "7.2.1"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.1.tgz#8dedf784901014db2feca1ab633864dee68ad985"
integrity sha512-T+VfD/bvgGTUA74iW9d2i5THrDQWbweXP0AVNI8tNd1Rk5ch1rnMiJkDD67ejw7YBKM4+REvcvqRuWJb7BLuEg==
react-redux@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.0.1.tgz#2bc029f5ada9b443107914c373a2750f6bc0f40c"
integrity sha512-LMZMsPY4DYdZfLJgd7i79n5Kps5N9XVLCJJeWAaPYTV+Eah2zTuBjTxKtNEbjiyitbq80/eIkm55CYSLqAub3w==
dependencies:
"@babel/runtime" "^7.5.5"
hoist-non-react-statics "^3.3.0"
loose-envify "^1.4.0"
prop-types "^15.7.2"
react-is "^16.9.0"
"@babel/runtime" "^7.12.1"
"@types/hoist-non-react-statics" "^3.3.1"
"@types/use-sync-external-store" "^0.0.3"
hoist-non-react-statics "^3.3.2"
react-is "^18.0.0"
use-sync-external-store "^1.0.0"
react-router-dom@^5.1.2:
version "5.2.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662"
integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==
react-router-dom@^6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d"
integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==
dependencies:
"@babel/runtime" "^7.1.2"
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"
history "^5.2.0"
react-router "6.3.0"
react-router@5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293"
integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==
react-router@6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557"
integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==
dependencies:
"@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"
history "^5.2.0"
react-scripts@3.4.1:
version "3.4.1"
@ -9395,6 +9806,25 @@ relateurl@^0.2.7:
resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
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:
version "1.1.0"
resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
@ -9632,6 +10062,13 @@ rxjs@^6.5.3, rxjs@^6.6.0:
dependencies:
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:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@ -10085,6 +10522,11 @@ source-map@^0.5.0, source-map@^0.5.6:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
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:
version "3.1.1"
resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
@ -10402,6 +10844,13 @@ style-loader@0.23.1:
loader-utils "^1.1.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:
version "5.1.1"
resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.1.1.tgz#96dfb02a8025794960863b9e8e365e3b6be5518d"
@ -10596,7 +11045,7 @@ tiny-invariant@^1.0.2:
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875"
integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==
tiny-warning@^1.0.0, tiny-warning@^1.0.3:
tiny-warning@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
@ -10691,6 +11140,11 @@ tree-changes@^0.9.0:
"@gilbarbara/deep-equal" "^0.1.0"
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:
version "1.1.6"
resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.1.6.tgz#389a24396d425a0d3162e96d2b4638900fdc289a"
@ -10803,6 +11257,19 @@ 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"
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:
version "1.0.1"
resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
@ -10837,6 +11304,71 @@ unique-slug@^2.0.0:
dependencies:
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:
version "0.1.2"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
@ -10902,6 +11434,11 @@ url@^0.11.0:
punycode "1.3.2"
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:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
@ -10959,6 +11496,16 @@ uuid@^3.0.1, uuid@^3.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
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:
version "2.1.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745"
@ -10996,6 +11543,24 @@ verror@1.10.0:
core-util-is "1.0.2"
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:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"