Compare commits

...

7 Commits

Author SHA1 Message Date
958edd7556 Small fix tweaks
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-05-22 20:17:03 +00:00
2d680d7142 Navbar compatible search box 2023-05-22 19:11:42 +00:00
ecbfb2d9ca Component-ised search bar 2023-05-22 17:55:03 +00:00
ec38525a43 Improved loading throughout 2023-05-22 17:26:29 +00:00
d90b40d6f2 Add a PatternLoader component 2023-05-22 16:48:06 +00:00
edd9c1e3ee Use loading indicators on discover card 2023-05-22 16:38:23 +00:00
f701bc46d8 Explore area from marketing homepage 2023-05-21 22:26:05 +00:00
14 changed files with 353 additions and 279 deletions

Binary file not shown.

View File

@ -17,6 +17,7 @@
"react-app-polyfill": "^0.2.0", "react-app-polyfill": "^0.2.0",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-confirm": "^0.1.18", "react-confirm": "^0.1.18",
"react-content-loader": "^6.2.1",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-helmet": "^6.0.0", "react-helmet": "^6.0.0",
"react-joyride": "^2.4.0", "react-joyride": "^2.4.0",

View File

@ -1,4 +1,6 @@
import api from '.'; import api from '.';
import actions from '../actions';
import { store } from '..';
export const groups = { export const groups = {
create(data, success, fail) { create(data, success, fail) {
@ -8,6 +10,7 @@ export const groups = {
api.authenticatedRequest('DELETE', `/groups/${id}`, null, success, fail); api.authenticatedRequest('DELETE', `/groups/${id}`, null, success, fail);
}, },
getMine(success, fail) { getMine(success, fail) {
store.dispatch(actions.groups.request());
api.authenticatedRequest('GET', '/groups', null, data => success && success(data.groups), fail); api.authenticatedRequest('GET', '/groups', null, data => success && success(data.groups), fail);
}, },
get(id, success, fail) { get(id, success, fail) {

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, List } from 'semantic-ui-react'; import { Card, List, Dimmer } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { BulletList } from 'react-content-loader'
import UserChip from './UserChip'; import UserChip from './UserChip';
import api from '../../api'; import api from '../../api';
import utils from '../../utils/utils.js'; import utils from '../../utils/utils.js';
@ -8,23 +9,25 @@ import utils from '../../utils/utils.js';
export default function ExploreCard({ count }) { export default function ExploreCard({ count }) {
const [highlightProjects, setHighlightProjects] = useState([]); const [highlightProjects, setHighlightProjects] = useState([]);
const [highlightUsers, setHighlightUsers] = useState([]); const [highlightUsers, setHighlightUsers] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
setLoading(true);
api.search.discover(count || 3, ({ highlightProjects, highlightUsers }) => { api.search.discover(count || 3, ({ highlightProjects, highlightUsers }) => {
setHighlightProjects(highlightProjects); setHighlightProjects(highlightProjects);
setHighlightUsers(highlightUsers); setHighlightUsers(highlightUsers);
setLoading(false);
}); });
}, []); }, []);
if ((highlightProjects?.length === 0 || highlightUsers?.length === 0)) return null;
return ( return (
<Card fluid> <Card fluid>
<Card.Content> <Card.Content>
<h4>Discover a project</h4>
{loading && <BulletList />}
{highlightProjects?.length > 0 && <> {highlightProjects?.length > 0 && <>
<h4>Discover a project</h4>
<List relaxed> <List relaxed>
{highlightProjects.map(p => {highlightProjects?.map(p =>
<List.Item key={p._id}> <List.Item key={p._id}>
<List.Icon name='book' size='large' verticalAlign='middle' /> <List.Icon name='book' size='large' verticalAlign='middle' />
<List.Content> <List.Content>
@ -35,10 +38,11 @@ export default function ExploreCard({ count }) {
</List> </List>
</>} </>}
<h4>Find others on {utils.appName()}</h4>
{loading && <BulletList />}
{highlightUsers?.length > 0 && <> {highlightUsers?.length > 0 && <>
<h4>Find others on {utils.appName()}</h4>
<List relaxed> <List relaxed>
{highlightUsers.map(u => {highlightUsers?.map(u =>
<List.Item key={u._id}> <List.Item key={u._id}>
<List.Content> <List.Content>
<UserChip user={u} className='umami--click--discover-user'/> <UserChip user={u} className='umami--click--discover-user'/>

View File

@ -1,8 +1,8 @@
import React, { useEffect } from 'react'; import React from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import { Loader, List, Popup, Modal, Grid, Icon, Button, Container, Dropdown } from 'semantic-ui-react'; import { Modal, Menu, Button, Container, Dropdown } from 'semantic-ui-react';
import api from '../../api'; import api from '../../api';
import actions from '../../actions'; import actions from '../../actions';
import utils from '../../utils/utils.js'; import utils from '../../utils/utils.js';
@ -10,6 +10,7 @@ import utils from '../../utils/utils.js';
import logo from '../../images/logo/main.png'; import logo from '../../images/logo/main.png';
import UserChip from './UserChip'; import UserChip from './UserChip';
import SupporterBadge from './SupporterBadge'; import SupporterBadge from './SupporterBadge';
import SearchBar from './SearchBar';
const StyledNavBar = styled.div` const StyledNavBar = styled.div`
height:60px; height:60px;
@ -18,12 +19,10 @@ const StyledNavBar = styled.div`
.logo{ .logo{
height:40px; height:40px;
margin-top:5px; margin-top:5px;
} margin-right: 50px;
.nav-links{ transition: opacity 0.3s;
display: flex; &:hover{
align-items: center; opacity: 0.5;
.ui.button{
margin-right: 8px;
} }
} }
.only-mobile{ .only-mobile{
@ -33,56 +32,23 @@ const StyledNavBar = styled.div`
} }
.above-mobile{ .above-mobile{
@media only screen and (max-width: 767px) { @media only screen and (max-width: 767px) {
display:none; display:none !important;
} }
} }
`; `;
const SearchBar = styled.div` export default function NavBar() {
background-color:rgba(0,0,0,0.1); const navigate = useNavigate();
padding-left:5px; const location = useLocation();
padding-top: 3px;
border: none;
border-radius:5px;
transition: background-color 0.5s;
margin-right:8px;
display:inline-block;
&:before{
display:inline-block;
content: '🔍';
}
&:focus-within{
background-color:rgba(250,250,250,0.5);
color:rgb(50,50,50);
input {
outline: none;
}
input::placeholder {
color:black;
}
}
input{
border:none;
background:none;
padding:8px;
}
`;
function NavBar() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { isAuthenticated, user, groups, helpModalOpen, searchPopupOpen, searchTerm, searchResults, searching } = useSelector(state => { const { isAuthenticated, user, groups, helpModalOpen } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[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)); const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
const { isAuthenticated } = state.auth; const { isAuthenticated } = state.auth;
const { helpModalOpen, searchPopupOpen, searchTerm, searchResults, searching } = state.app; const { helpModalOpen } = state.app;
return { isAuthenticated, user, groups, helpModalOpen, searchPopupOpen, searchTerm, searchResults, searching }; return { isAuthenticated, user, groups, helpModalOpen };
}); });
const navigate = useNavigate();
useEffect(() => {
dispatch(actions.app.openSearchPopup(false));
}, [dispatch]);
const logout = () => api.auth.logout(() => { const logout = () => api.auth.logout(() => {
dispatch(actions.auth.logout()); dispatch(actions.auth.logout());
dispatch(actions.users.syncDrift(false)) dispatch(actions.users.syncDrift(false))
@ -90,77 +56,17 @@ function NavBar() {
navigate('/'); navigate('/');
}); });
const search = () => {
dispatch(actions.app.updateSearching(true));
api.search.all(searchTerm, r => dispatch(actions.app.updateSearchResults(r)));
};
return ( return (
<StyledNavBar> <StyledNavBar>
<Container style={{display:'flex', justifyContent: 'space-between', alignItems: 'center'}}> <Container style={{display:'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<Link to="/"><img alt={`${utils.appName()} logo`} src={logo} className="logo" /></Link> <Link to="/"><img alt={`${utils.appName()} logo`} src={logo} className="logo" /></Link>
{isAuthenticated <div style={{flex: 1}}>
? ( <Menu secondary>
<div className='nav-links'> <Menu.Item className='above-mobile' as={Link} to='/' name='home' active={location.pathname === '/'} />
<Popup basic on='focus' open={searchPopupOpen} <Menu.Item className='above-mobile' as={Link} to='/explore' name='explore' active={location.pathname === '/explore'} />
onOpen={e => dispatch(actions.app.openSearchPopup(true))} onClose={e => dispatch(actions.app.openSearchPopup(false))} <Menu.Item className='above-mobile' active={location.pathname.startsWith('/groups')} name='Groups'>
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>} <Dropdown pointing='top left'
content={<div style={{width: 300}} className='joyride-search'> trigger={<span>Groups</span>}
{!searchResults?.users && !searchResults?.groups ?
<small>
{searching
? <span><Loader size='tiny' inline active style={{marginRight: 10}}/> Searching...</span>
: <span>Type something and press enter to search</span>
}
</small>
: <>
{(!searchResults.users?.length && !searchResults?.groups?.length && !searchResults?.projects?.length) ?
<span><small>No results found</small></span>
:
<Grid stackable>
{searchResults?.users?.length > 0 &&
<Grid.Column width={6}>
{searchResults?.users?.map(u =>
<div style={{marginBottom: 5}}><UserChip user={u} key={u._id} /></div>
)}
</Grid.Column>
}
{(searchResults?.projects.length > 0 || searchResults.groups.length > 0) &&
<Grid.Column width={10}>
<List>
{searchResults?.projects?.map(p =>
<List.Item key={p._id}>
<List.Icon name='book' size='large' verticalAlign='middle' />
<List.Content>
<List.Header as={Link} to={'/' + p.fullName}>{p.name}</List.Header>
<List.Description><UserChip compact user={p.owner} /></List.Description>
</List.Content>
</List.Item>
)}
{searchResults?.groups?.map(g =>
<List.Item key={g._id}>
<List.Icon name='users' size='large' verticalAlign='middle' />
<List.Content>
<List.Header as={Link} to={`/groups/${g._id}`}>{g.name}</List.Header>
<List.Description><small>{g.closed ? <span><Icon name='lock' /> Closed group</span> : <span>Open group</span>}</small></List.Description>
</List.Content>
</List.Item>
)}
</List>
</Grid.Column>
}
</Grid>
}</>}
</div>} />
<span className='above-mobile'>
<Button as={Link} to="/" size="small" icon='home' basic content='Home' />
</span>
{groups.length > 0 &&
<span className='above-mobile'>
<Dropdown icon={null} direction='left' pointing='top right'
trigger={<Button size='small' icon='users' basic content='Groups'/>}
> >
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Header icon='users' content='Your groups' /> <Dropdown.Header icon='users' content='Your groups' />
@ -171,86 +77,79 @@ function NavBar() {
<Dropdown.Item as={Link} to='/groups/new' icon='plus' content='Create a new group' /> <Dropdown.Item as={Link} to='/groups/new' icon='plus' content='Create a new group' />
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
</span> </Menu.Item>
}
<span className='above-mobile'> <Menu.Menu position='right'>
<Button size='small' icon='help' basic onClick={e => dispatch(actions.app.openHelpModal(true))}/> {isAuthenticated && <>
</span> <Menu.Item className='above-mobile'><SearchBar /></Menu.Item>
<Dropdown direction="left" pointing="top right" icon={null} style={{ marginTop: 10}}
trigger={<UserChip user={user} withoutLink avatarOnly />}
>
<Dropdown.Menu style={{ minWidth: '200px', paddingTop: 10 }}>
{user &&
<Dropdown.Header as={Link} to={`/${user.username}`}>
<UserChip user={user} />
</Dropdown.Header>
}
{user?.isGoldSupporter && <Dropdown.Header><SupporterBadge type='gold' /></Dropdown.Header>}
{user?.isSilverSupporter && !user?.isGoldSupporter && <Dropdown.Header><SupporterBadge type='silver' /></Dropdown.Header>}
<Dropdown.Divider />
<Link to="/" className="item">Projects</Link>
{user &&<Link to={`/${user.username}`} className="item">Profile</Link>}
<Link to="/settings" className="item">Settings</Link>
<Dropdown.Divider />
{user?.roles?.indexOf('root') > -1 &&
<Dropdown.Item as={Link} to='/root'>Root</Dropdown.Item>
}
<Dropdown.Item as={Link} to='/docs'>Help</Dropdown.Item>
<Dropdown.Item onClick={e => dispatch(actions.app.openHelpModal(true))}>About {utils.appName()}</Dropdown.Item>
<Dropdown.Item onClick={logout}>Logout</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>}
<Dropdown direction="left" pointing="top right" icon={null} style={{marginLeft: 10, marginTop: 5}} {!isAuthenticated && <>
trigger={<UserChip user={user} withoutLink avatarOnly />} <Menu.Item name='Login' onClick={() => dispatch(actions.auth.openLogin())} />
> <Menu.Item>
<Dropdown.Menu style={{ minWidth: '200px', paddingTop: 10 }}> <Button size='small' color="teal" onClick={() => dispatch(actions.auth.openRegister())}>
{user && <span role="img" aria-label="wave">👋</span> Sign-up
<Dropdown.Header as={Link} to={`/${user.username}`}> </Button>
<UserChip user={user} /> </Menu.Item>
</Dropdown.Header> </>}
} </Menu.Menu>
{user?.isGoldSupporter && <Dropdown.Header><SupporterBadge type='gold' /></Dropdown.Header>} </Menu>
{user?.isSilverSupporter && !user?.isGoldSupporter && <Dropdown.Header><SupporterBadge type='silver' /></Dropdown.Header>} </div>
<Dropdown.Divider /> </Container>
<Link to="/" className="item">Projects</Link> <AboutModal open={helpModalOpen} onClose={e => dispatch(actions.app.openHelpModal(false))} />
{user &&<Link to={`/${user.username}`} className="item">Profile</Link>} </StyledNavBar>
<Link to="/settings" className="item">Settings</Link> );
<Dropdown.Divider /> }
{user?.roles?.indexOf('root') > -1 &&
<Dropdown.Item as={Link} to='/root'>Root</Dropdown.Item>
}
<Dropdown.Item as={Link} to='/docs'>Help</Dropdown.Item>
<Dropdown.Item onClick={logout}>Logout</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
)
: (
<div className='nav-links'>
<span className="only-mobile">
<Dropdown
icon={null}
trigger={<Button basic icon="bars" />}
>
<Dropdown.Menu direction="left">
<Dropdown.Item onClick={() => dispatch(actions.auth.openLogin())}>Login</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</span>
<span className="above-mobile">
<Button basic color='teal' onClick={() => dispatch(actions.auth.openLogin())}>Login</Button>
</span>
<Button color="teal" onClick={() => dispatch(actions.auth.openRegister())}>
<span role="img" aria-label="wave">👋</span> Sign-up
</Button>
</div>
)
}
<Modal open={helpModalOpen} onClose={e => dispatch(actions.app.openHelpModal(false))}>
<Modal.Header>Welcome to {utils.appName()}!</Modal.Header>
<Modal.Content>
<h3>Introduction</h3>
<p>{utils.appName()} has been designed as a resource for weavers not only for those working alone as individuals, but also for groups who wish to share ideas, design inspirations and weaving patterns. It is ideal for those looking for a depository to store their individual work, and also for groups such as guilds, teaching groups, or any other collaborative working partnerships.</p>
<p>Projects can be created within {utils.appName()} using the integral WIF-compatible draft editor, or alternatively files can be imported from other design software along with supporting images and other information you may wish to be saved within the project file. Once complete, projects may be stored privately, shared within a closed group, or made public for other {utils.appName()} users to see. The choice is yours!</p>
<h3>Getting started</h3> function AboutModal({ open, onClose }) {
<p><strong>Creating a profile:</strong> You can add a picture, links to a personal website, and other social media accounts to tell others more about yourself.</p> return (
<p><strong>Creating a group:</strong> You have the option to do things alone, or create a group. By clicking on the Create a group button, you can name your group, and then invite members via email or directly through {utils.appName()} if they are existing {utils.appName()} users.</p> <Modal open={open} onClose={e => onClose()}>
<p><strong>Creating a new project:</strong> When you are ready to create/store a project on the system, you are invited to give the project a name, and a brief description. You will then be taken to a Welcome to your project screen, where if you click on add something, you have the option of creating a new weaving pattern directly inside {utils.appName()} or you can simply import a WIF file from your preferred weaving software. Once imported, you can perform further editing within {utils.appName()}, or you can add supporting picture files and any other additional information you wish to keep (eg weaving notes, yarn details etc).</p> <Modal.Header>Welcome to {utils.appName()}!</Modal.Header>
<p>Once complete you then have the option of saving the file privately, shared within a group, or made public for other {utils.appName()} users to see.</p> <Modal.Content>
<h3>Introduction</h3>
<p>{utils.appName()} has been designed as a resource for weavers not only for those working alone as individuals, but also for groups who wish to share ideas, design inspirations and weaving patterns. It is ideal for those looking for a depository to store their individual work, and also for groups such as guilds, teaching groups, or any other collaborative working partnerships.</p>
<p>Projects can be created within {utils.appName()} using the integral WIF-compatible draft editor, or alternatively files can be imported from other design software along with supporting images and other information you may wish to be saved within the project file. Once complete, projects may be stored privately, shared within a closed group, or made public for other {utils.appName()} users to see. The choice is yours!</p>
<h3>Help and support</h3> <h3>Getting started</h3>
<p>The documentation provides useful information that might help you if you get stuck.</p> <p><strong>Creating a profile:</strong> You can add a picture, links to a personal website, and other social media accounts to tell others more about yourself.</p>
<Button as='a' href='/docs' target='_blank' rel='noopener noreferrer'>View the docs</Button> <p><strong>Creating a group:</strong> You have the option to do things alone, or create a group. By clicking on the Create a group button, you can name your group, and then invite members via email or directly through {utils.appName()} if they are existing {utils.appName()} users.</p>
<p><strong>Creating a new project:</strong> When you are ready to create/store a project on the system, you are invited to give the project a name, and a brief description. You will then be taken to a Welcome to your project screen, where if you click on add something, you have the option of creating a new weaving pattern directly inside {utils.appName()} or you can simply import a WIF file from your preferred weaving software. Once imported, you can perform further editing within {utils.appName()}, or you can add supporting picture files and any other additional information you wish to keep (eg weaving notes, yarn details etc).</p>
<p>Once complete you then have the option of saving the file privately, shared within a group, or made public for other {utils.appName()} users to see.</p>
<h3>We hope you enjoy using {utils.appName()}</h3> <h3>Help and support</h3>
<p>If you have any comments or feedback please tell us by emailing <a href={`mailTo:${import.meta.env.VITE_CONTACT_EMAIL}`}>{import.meta.env.VITE_CONTACT_EMAIL}</a>!</p> <p>The documentation provides useful information that might help you if you get stuck.</p>
</Modal.Content> <Button as='a' href='/docs' target='_blank' rel='noopener noreferrer'>View the docs</Button>
<Modal.Actions>
<Button onClick={e => dispatch(actions.app.openHelpModal(false))} color='teal' icon='check' content='OK' />
</Modal.Actions>
</Modal>
</Container>
</StyledNavBar>
);
}
export default NavBar; <h3>We hope you enjoy using {utils.appName()}</h3>
<p>If you have any comments or feedback please tell us by emailing <a href={`mailTo:${import.meta.env.VITE_CONTACT_EMAIL}`}>{import.meta.env.VITE_CONTACT_EMAIL}</a>!</p>
</Modal.Content>
<Modal.Actions>
<Button onClick={e => onClose()} color='teal' icon='check' content='OK' />
</Modal.Actions>
</Modal>
);
}

View File

@ -0,0 +1,25 @@
import React from 'react';
import { Card } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import UserChip from './UserChip';
export default function PatternCard({ object, project, user }) {
if (!object) return null;
return (
<Card raised key={object._id} style={{ cursor: 'pointer' }} as={Link} to={`/${user?.username}/${project?.path}/${object._id}`}>
<div style={{ height: 200, backgroundImage: `url(${object.preview})`, backgroundSize: 'cover', backgroundPosition: 'top right', position: 'relative' }}>
{user &&
<div style={{position: 'absolute', top: 5, left: 5, padding: '3px 6px', background: 'rgba(250,250,250,0.8)', borderRadius: 5}}>
<UserChip user={user} />
</div>
}
</div>
<Card.Content>
<p style={{ wordBreak: 'break-all' }}>{object.name}</p>
{project?.path &&
<p style={{fontSize: 11, color: 'black'}}>{project.path}</p>
}
</Card.Content>
</Card>
);
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import { Instagram, List } from 'react-content-loader';
import { Card } from 'semantic-ui-react';
export default function PatternLoader({ count, isCompact }) {
if (!count) count = 1;
return (<>
{[...new Array(count)].map((item, index) =>
<Card key={index}>{isCompact ? <Card.Content><List /></Card.Content> : <Instagram />}</Card>
)}
</>);
}

View File

@ -0,0 +1,97 @@
import React, { useEffect } from 'react';
import styled from 'styled-components';
import { Popup, Loader, Grid, List, Input } from 'semantic-ui-react';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import actions from '../../actions';
import api from '../../api';
import UserChip from './UserChip';
const StyledSearchBar = styled.div`
background-color:rgba(0,0,0,0.1);
padding:5px;
border: none;
border-radius:5px;
transition: background-color 0.5s;
&:focus-within{
background-color:rgba(250,250,250,0.5);
}
input::placeholder {
color: black !important;
}
`;
export default function SearchBar() {
const dispatch = useDispatch();
const { searchPopupOpen, searchTerm, searchResults, searching } = useSelector(state => {
const { searchPopupOpen, searchTerm, searchResults, searching } = state.app;
return { searchPopupOpen, searchTerm, searchResults, searching };
});
useEffect(() => {
dispatch(actions.app.openSearchPopup(false));
}, [dispatch]);
const search = () => {
dispatch(actions.app.updateSearching(true));
api.search.all(searchTerm, r => dispatch(actions.app.updateSearchResults(r)));
};
return (
<Popup basic on='focus' open={searchPopupOpen}
onOpen={e => dispatch(actions.app.openSearchPopup(true))} onClose={e => dispatch(actions.app.openSearchPopup(false))}
trigger={
<StyledSearchBar><Input transparent size='small' placeholder='Search...' icon='search' iconPosition='left' value={searchTerm} onChange={e => dispatch(actions.app.updateSearchTerm(e.target.value))} onKeyDown={e => e.keyCode === 13 && search()} /></StyledSearchBar>
}
content={<div style={{width: 300}} className='joyride-search'>
{!searchResults?.users && !searchResults?.groups ?
<small>
{searching
? <span><Loader size='tiny' inline active style={{marginRight: 10}}/> Searching...</span>
: <span>Type something and press enter to search</span>
}
</small>
: <>
{(!searchResults.users?.length && !searchResults?.groups?.length && !searchResults?.projects?.length) ?
<span><small>No results found</small></span>
:
<Grid stackable>
{searchResults?.users?.length > 0 &&
<Grid.Column width={6}>
{searchResults?.users?.map(u =>
<div style={{marginBottom: 5}}><UserChip user={u} key={u._id} /></div>
)}
</Grid.Column>
}
{(searchResults?.projects.length > 0 || searchResults.groups.length > 0) &&
<Grid.Column width={10}>
<List>
{searchResults?.projects?.map(p =>
<List.Item key={p._id}>
<List.Icon name='book' size='large' verticalAlign='middle' />
<List.Content>
<List.Header as={Link} to={'/' + p.fullName}>{p.name}</List.Header>
<List.Description><UserChip compact user={p.owner} /></List.Description>
</List.Content>
</List.Item>
)}
{searchResults?.groups?.map(g =>
<List.Item key={g._id}>
<List.Icon name='users' size='large' verticalAlign='middle' />
<List.Content>
<List.Header as={Link} to={`/groups/${g._id}`}>{g.name}</List.Header>
<List.Description><small>{g.closed ? <span><Icon name='lock' /> Closed group</span> : <span>Open group</span>}</small></List.Description>
</List.Content>
</List.Item>
)}
</List>
</Grid.Column>
}
</Grid>
}</>}
</div>}
/>
);
}

View File

@ -3,6 +3,7 @@ import { Helmet } from 'react-helmet';
import { Loader, Divider, Button, Message, Container, Segment, Grid, Card, Icon, List } from 'semantic-ui-react'; import { Loader, Divider, Button, Message, Container, Segment, Grid, Card, Icon, List } from 'semantic-ui-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { BulletList } from 'react-content-loader'
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import actions from '../../actions'; import actions from '../../actions';
import api from '../../api'; import api from '../../api';
@ -11,18 +12,19 @@ import utils from '../../utils/utils.js';
import UserChip from '../includes/UserChip'; import UserChip from '../includes/UserChip';
import HelpLink from '../includes/HelpLink'; import HelpLink from '../includes/HelpLink';
import ProjectCard from '../includes/ProjectCard'; import ProjectCard from '../includes/ProjectCard';
import PatternLoader from '../includes/PatternLoader';
import Tour from '../includes/Tour'; import Tour from '../includes/Tour';
import DiscoverCard from '../includes/DiscoverCard'; import DiscoverCard from '../includes/DiscoverCard';
function Home() { function Home() {
const [runJoyride, setRunJoyride] = useState(false); const [runJoyride, setRunJoyride] = useState(false);
const dispatch = useDispatch(); const dispatch = useDispatch();
const { user, projects, groups, invitations, loadingProjects } = useSelector(state => { const { user, projects, groups, invitations, loadingProjects, loadingGroups } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[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)); const groups = state.groups.groups.filter(g => utils.isInGroup(user, g._id));
const invitations = state.invitations.invitations.filter(i => i.recipient === user?._id); const invitations = state.invitations.invitations.filter(i => i.recipient === user?._id);
const projects = state.projects.projects.filter(p => p.user === user?._id); const projects = state.projects.projects.filter(p => p.user === user?._id);
return { user, projects, groups, invitations, loadingProjects: state.projects.loading }; return { user, projects, groups, invitations, loadingProjects: state.projects.loading, loadingGroups: state.groups.loading };
}); });
useEffect(() => { useEffect(() => {
@ -89,41 +91,48 @@ function Home() {
<DiscoverCard count={3} /> <DiscoverCard count={3} />
{(groups && groups.length) ? <Card fluid className='joyride-groups' style={{opacity: 0.8}}>
<Card fluid className='joyride-groups' style={{opacity: 0.8}}> <Card.Content>
<Card.Content> <Card.Header>Your groups</Card.Header>
<Card.Header>Your groups</Card.Header>
<List relaxed> {(loadingGroups && !groups?.length) ?
{groups.map(g => <div>
<List.Item key={g._id}> <BulletList />
<List.Icon name='users' size='large' verticalAlign='middle' /> <BulletList />
<List.Content> </div>
<List.Header as={Link} to={`/groups/${g._id}`}>{g.name}</List.Header> :
<List.Description>{utils.isGroupAdmin(user, g) ? 'Administrator' : 'Member'}</List.Description> (groups?.length > 0 ?
</List.Content> <List relaxed>
</List.Item> {groups.map(g =>
)} <List.Item key={g._id}>
</List> <List.Icon name='users' size='large' verticalAlign='middle' />
<Button className='joyride-createGroup' fluid size='small' icon='plus' content='Create a new group' as={Link} to='/groups/new' /> <List.Content>
<HelpLink link={`/docs/groups`} text='Learn more about groups' marginTop/> <List.Header as={Link} to={`/groups/${g._id}`}>{g.name}</List.Header>
</Card.Content> <List.Description>{utils.isGroupAdmin(user, g) ? 'Administrator' : 'Member'}</List.Description>
</Card> </List.Content>
: </List.Item>
<Message> )}
<Message.Header>Groups</Message.Header> </List>
<p>Groups enable you to build communities of weavers and makers with similar interests. Create one for your weaving group or class today.</p> :
<Button className='joyride-createGroup' as={Link} to='/groups/new' size='small' color='purple' icon='plus' content='Create a group' /> <Card.Description>
</Message> Groups enable you to join or build communities of weavers and makers with similar interests.
} </Card.Description>
)
}
<Divider hidden />
<Button className='joyride-createGroup' fluid size='small' icon='plus' content='Create a new group' as={Link} to='/groups/new' />
<HelpLink link={`/docs/groups`} text='Learn more about groups' marginTop/>
</Card.Content>
</Card>
{(import.meta.env.VITE_PATREON_URL || import.meta.env.VITE_KOFI_URL) && {(import.meta.env.VITE_PATREON_URL || import.meta.env.VITE_KOFI_URL) &&
<Card fluid style={{opacity: 0.8}}> <Card fluid style={{opacity: 0.8}}>
<Card.Content> <Card.Content>
<Card.Header><span role="img" aria-label="Dancer">🕺</span> Support {utils.appName()}</Card.Header> <Card.Header><span role="img" aria-label="Dancer">🕺</span> Support {utils.appName()}</Card.Header>
<Card.Description>{utils.appName()} is offered free of charge, but costs money to run and build. If you get value out of {utils.appName()} you may like to consider supporting it.</Card.Description> <Card.Description>{utils.appName()} is offered free of charge, but costs money to run and build. If you get value out of {utils.appName()} you may like to consider supporting it.</Card.Description>
<Divider hidden />
{import.meta.env.VITE_KOFI_URL && {import.meta.env.VITE_KOFI_URL &&
<Button style={{marginTop: 10}} size='small' fluid as='a' href={import.meta.env.VITE_KOFI_URL} target='_blank' rel='noopener noreferrer' className='umami--click--kofi-button'><span role='img' aria-label='Coffee' style={{marginRight: 5}}></span> Buy me a coffee</Button> <Button size='small' fluid as='a' href={import.meta.env.VITE_KOFI_URL} target='_blank' rel='noopener noreferrer' className='umami--click--kofi-button'><span role='img' aria-label='Coffee' style={{marginRight: 5}}></span> Buy me a coffee</Button>
} }
{import.meta.env.VITE_PATREON_URL && {import.meta.env.VITE_PATREON_URL &&
<Button style={{marginTop: 10}} size='small' fluid as='a' href={import.meta.env.VITE_PATREON_URL} target='_blank' rel='noopener noreferrer' className='umami--click--patreon-button'><span role='img' aria-label='Party' style={{marginRight: 5}}>🥳</span> Become a patron</Button> <Button style={{marginTop: 10}} size='small' fluid as='a' href={import.meta.env.VITE_PATREON_URL} target='_blank' rel='noopener noreferrer' className='umami--click--patreon-button'><span role='img' aria-label='Party' style={{marginRight: 5}}>🥳</span> Become a patron</Button>
@ -135,42 +144,39 @@ function Home() {
</Grid.Column> </Grid.Column>
<Grid.Column computer={11} className='joyride-projects'> <Grid.Column computer={11} className='joyride-projects'>
{loadingProjects && !projects.length && <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<div style={{textAlign: 'center'}}> <h2><Icon name='book' /> Your projects</h2>
<h4>Loading your projects...</h4> <div><Button className='joyride-createProject' as={Link} to="/projects/new" color='teal' content='Create a project' icon='plus' /></div>
<Loader active inline="centered" /> </div>
</div> <p>Projects contain the patterns and files that make up your creations.
<HelpLink className='joyride-help' link={`/docs/projects`} text='Learn more about projects' marginLeft/>
</p>
<Divider hidden />
{loadingProjects && !projects?.length &&
<Card.Group itemsPerRow={2} stackable>
<PatternLoader isCompact count={3} />
</Card.Group>
} }
{user && !loadingProjects && (!projects || !projects.length) && {user && !loadingProjects && !projects?.length &&
<div style={{textAlign: 'center'}}> <div style={{textAlign: 'center'}}>
<h1>
<span role="img" aria-label="chequered flag">🚀</span> Let's get started
</h1>
<Divider hidden/>
<Segment placeholder textAlign='center'> <Segment placeholder textAlign='center'>
<h3>On {utils.appName()}, your patterns and files are stored in <strong><span role="img" aria-label="box">📦</span> projects</strong></h3> <h3>On {utils.appName()}, your patterns and files are stored in <strong><span role="img" aria-label="box">📦</span> projects</strong></h3>
<p>Projects can contain anything: from rough ideas or design experiments through to commissions and exhibitions. Treat them as if they were just <span role="img" aria-label="folder">📁</span> folders on your computer.</p> <p>Projects can contain anything: from rough ideas or design experiments through to commissions and exhibitions. Treat them as if they were just <span role="img" aria-label="folder">📁</span> folders on your computer.</p>
<p><HelpLink className='joyride-help' link={`/docs/projects`} text='Learn more about projects' marginTop/></p> <Divider section hidden />
<Divider /> <h4>Start by creating your first project. You can keep it private if you prefer.</h4>
<h4>Start by creating a new project. Don't worry, you can keep it private.</h4>
<Button className='joyride-createProject' as={Link} to="/projects/new" color="teal" icon="plus" content="Create a project" /> <Button className='joyride-createProject' as={Link} to="/projects/new" color="teal" icon="plus" content="Create a project" />
</Segment> </Segment>
</div> </div>
} }
{projects && projects.length > 0 && {projects?.length > 0 &&
<div> <div>
<Button className='joyride-createProject' as={Link} to="/projects/new" color='teal' content='Create a project' icon='plus' floated='right'/>
<h2><Icon name='book' /> Your projects</h2>
<p>Projects contain the patterns and files that make up your creations.
<HelpLink className='joyride-help' link={`/docs/projects`} text='Learn more about projects' marginLeft/>
</p>
<Divider clearing hidden />
<Card.Group itemsPerRow={2} stackable> <Card.Group itemsPerRow={2} stackable>
{projects && projects.map(proj => ( {projects.map(proj => (
<ProjectCard key={proj._id} project={proj} /> <ProjectCard key={proj._id} project={proj} />
))} ))}
</Card.Group> </Card.Group>

View File

@ -1,13 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Container, Card, Grid, Button } from 'semantic-ui-react'; import { Container, Card, Grid, Button } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import actions from '../../../actions'; import actions from '../../../actions';
import api from '../../../api'; import api from '../../../api';
import utils from '../../../utils/utils.js'; import utils from '../../../utils/utils.js';
import UserChip from '../../includes/UserChip';
import DiscoverCard from '../../includes/DiscoverCard'; import DiscoverCard from '../../includes/DiscoverCard';
import PatternCard from '../../includes/PatternCard';
import PatternLoader from '../../includes/PatternLoader';
import DraftPreview from '../projects/objects/DraftPreview'; import DraftPreview from '../projects/objects/DraftPreview';
export default function Explore() { export default function Explore() {
@ -18,9 +18,9 @@ export default function Explore() {
return { objects: state.objects.exploreObjects, page: state.objects.explorePage }; return { objects: state.objects.exploreObjects, page: state.objects.explorePage };
}); });
useEffect(() => { /*useEffect(() => {
if (page < 2) loadMoreExplore(); if (page < 2) loadMoreExplore();
}, []); }, []);*/
function loadMoreExplore() { function loadMoreExplore() {
setLoading(true); setLoading(true);
@ -41,25 +41,14 @@ export default function Explore() {
<Card.Group stackable doubling itemsPerRow={3} style={{marginTop: 30}}> <Card.Group stackable doubling itemsPerRow={3} style={{marginTop: 30}}>
{objects?.filter(o => o.projectObject && o.userObject).map(object => {objects?.filter(o => o.projectObject && o.userObject).map(object =>
<Card raised key={object._id} style={{ cursor: 'pointer' }} as={Link} to={`/${object.userObject?.username}/${object.projectObject?.path}/${object._id}`}> <PatternCard key={object._id} object={object} project={object.projectObject} user={object.userObject} />
<div style={{ height: 200, backgroundImage: `url(${object.preview})`, backgroundSize: 'cover', backgroundPosition: 'top right', position: 'relative' }}>
{object.userObject &&
<div style={{position: 'absolute', top: 5, left: 5, padding: 3, background: 'rgba(250,250,250,0.5)', borderRadius: 5}}>
<UserChip user={object.userObject} />
</div>
}
</div>
<Card.Content>
<p style={{ wordBreak: 'break-all' }}>{object.name}</p>
{object.projectObject?.path &&
<p style={{fontSize: 11, color: 'black'}}>{object.projectObject.path}</p>
}
</Card.Content>
</Card>
)} )}
{objects?.length === 0 && <>
<PatternLoader count={6} />
</>}
</Card.Group> </Card.Group>
<div style={{display: 'flex', justifyContent: 'center', marginTop: 30}}> <div style={{display: 'flex', justifyContent: 'center', marginTop: 30}}>
<Button loading={loading} onClick={loadMoreExplore}>Load more</Button> <Button loading={loading} onClick={loadMoreExplore}>View more</Button>
</div> </div>
</Grid.Column> </Grid.Column>
</Grid> </Grid>

View File

@ -137,10 +137,11 @@ function ObjectViewer() {
</Dropdown> </Dropdown>
{user && {user &&
<Dropdown icon={null} trigger={<Button size="small" icon="copy" secondary content="Copy to.." />}> <Dropdown direction='left' icon={null} trigger={<Button size="small" icon="copy" secondary content="Copy to.." />}>
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Header>Select a project to copy this pattern to</Dropdown.Header> <Dropdown.Header>Select a project to copy this pattern to</Dropdown.Header>
{myProjects?.map(myProject => <Dropdown.Item content={myProject.name} onClick={e => copyPattern(myProject)} />)} {myProjects?.map(myProject => <Dropdown.Item content={myProject.name} onClick={e => copyPattern(myProject)} />)}
{myProjects?.length === 0 && <Dropdown.Item>You don't have any projects.</Dropdown.Item>}
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
} }

View File

@ -1,9 +1,13 @@
import React from 'react'; import React from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Divider, Grid, Button, Container } from 'semantic-ui-react'; import { Divider, Grid, Button, Container, Card } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { useSelector } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import utils from '../../utils/utils.js'; import utils from '../../utils/utils.js';
import PatternCard from '../includes/PatternCard';
import PatternLoader from '../includes/PatternLoader';
import projectImage from '../../images/project.png'; import projectImage from '../../images/project.png';
import toolsImage from '../../images/tools.png'; import toolsImage from '../../images/tools.png';
import filesImage from '../../images/files.png'; import filesImage from '../../images/files.png';
@ -11,10 +15,14 @@ import filesImage from '../../images/files.png';
const StyledHero = styled.div` const StyledHero = styled.div`
background: linen; background: linen;
min-height: 200px; min-height: 200px;
margin-top: 10px; padding-top: 50px;
`; `;
function MarketingHome({ onRegisterClicked }) { function MarketingHome({ onRegisterClicked }) {
const { objects } = useSelector(state => {
return { objects: state.objects.exploreObjects };
});
return ( return (
<div> <div>
<Helmet title='Home' /> <Helmet title='Home' />
@ -55,6 +63,25 @@ function MarketingHome({ onRegisterClicked }) {
</Container> </Container>
</StyledHero> </StyledHero>
<div style={{paddingTop: 75, marginBottom: 75, background: 'linear-gradient(linen, rgb(255,251,248)'}}>
<Container>
<div style={{display: 'flex', justifyContent:'space-between', alignItems: 'center'}}>
<h2>See what people have been creating</h2>
<Button size='large' as={Link} to='/explore' primary>Explore {utils.appName()}</Button>
</div>
<Card.Group stackable doubling itemsPerRow={3} style={{marginTop: 30}}>
{objects?.length > 0 &&
(objects?.filter(o => o.projectObject && o.userObject).slice(0, 2).map(object =>
<PatternCard object={object} project={object.projectObject} user={object.userObject} />
))
}
{objects?.length === 0 && <>
<PatternLoader count={3} />
</>}
</Card.Group>
</Container>
</div>
<Container style={{ marginTop: 50 }}> <Container style={{ marginTop: 50 }}>
<Grid stackable centered> <Grid stackable centered>
<Grid.Column computer={6} textAlign="right"> <Grid.Column computer={6} textAlign="right">

View File

@ -2881,6 +2881,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-content-loader@npm:^6.2.1":
version: 6.2.1
resolution: "react-content-loader@npm:6.2.1"
peerDependencies:
react: ">=16.0.0"
checksum: f777d408256a4218677e47f4cf3988d9fd8e556450e9b85ee1eb3952a5d5802573cea0df5eaf4dbc936c9522f355657de6f8ab0ecdf035d7dccdef15b45c9dae
languageName: node
linkType: hard
"react-dom@npm:^18.2.0": "react-dom@npm:^18.2.0":
version: 18.2.0 version: 18.2.0
resolution: "react-dom@npm:18.2.0" resolution: "react-dom@npm:18.2.0"
@ -3564,6 +3573,7 @@ __metadata:
react-app-polyfill: ^0.2.0 react-app-polyfill: ^0.2.0
react-color: ^2.19.3 react-color: ^2.19.3
react-confirm: ^0.1.18 react-confirm: ^0.1.18
react-content-loader: ^6.2.1
react-dom: ^18.2.0 react-dom: ^18.2.0
react-helmet: ^6.0.0 react-helmet: ^6.0.0
react-joyride: ^2.4.0 react-joyride: ^2.4.0