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>
}
<Menu.Menu position='right'>
{isAuthenticated && <>
<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>
</>}
{!isAuthenticated && <>
<Menu.Item name='Login' onClick={() => dispatch(actions.auth.openLogin())} />
<Menu.Item>
<Button size='small' color="teal" onClick={() => dispatch(actions.auth.openRegister())}>
<span role="img" aria-label="wave">👋</span> Sign-up
</Button>
</Menu.Item>
</>}
</Menu.Menu>
</Menu>
</div>
</Container>
<AboutModal open={helpModalOpen} onClose={e => dispatch(actions.app.openHelpModal(false))} />
</StyledNavBar>
);
}
function AboutModal({ open, onClose }) {
return (
<Modal open={open} onClose={e => onClose()}>
<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>
<span className='above-mobile'> <h3>Getting started</h3>
<Button size='small' icon='help' basic onClick={e => dispatch(actions.app.openHelpModal(true))}/> <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>
</span> <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>
<Dropdown direction="left" pointing="top right" icon={null} style={{marginLeft: 10, marginTop: 5}} <h3>Help and support</h3>
trigger={<UserChip user={user} withoutLink avatarOnly />} <p>The documentation provides useful information that might help you if you get stuck.</p>
> <Button as='a' href='/docs' target='_blank' rel='noopener noreferrer'>View the docs</Button>
<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={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> <h3>We hope you enjoy using {utils.appName()}</h3>
<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> <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><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.Content>
<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.Actions>
<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> <Button onClick={e => onClose()} color='teal' icon='check' content='OK' />
</Modal.Actions>
<h3>Help and support</h3> </Modal>
<p>The documentation provides useful information that might help you if you get stuck.</p> );
<Button as='a' href='/docs' target='_blank' rel='noopener noreferrer'>View the docs</Button> }
<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 => dispatch(actions.app.openHelpModal(false))} color='teal' icon='check' content='OK' />
</Modal.Actions>
</Modal>
</Container>
</StyledNavBar>
);
}
export default NavBar;

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(() => {
@ -88,42 +90,49 @@ function Home() {
<h2><span role="img" aria-label="wave">👋</span> {greeting}{user && <span>, {user.username}</span>}</h2> <h2><span role="img" aria-label="wave">👋</span> {greeting}{user && <span>, {user.username}</span>}</h2>
<DiscoverCard count={3} /> <DiscoverCard count={3} />
<Card fluid className='joyride-groups' style={{opacity: 0.8}}>
<Card.Content>
<Card.Header>Your groups</Card.Header>
{(groups && groups.length) ? {(loadingGroups && !groups?.length) ?
<Card fluid className='joyride-groups' style={{opacity: 0.8}}> <div>
<Card.Content> <BulletList />
<Card.Header>Your groups</Card.Header> <BulletList />
</div>
<List relaxed> :
{groups.map(g => (groups?.length > 0 ?
<List.Item key={g._id}> <List relaxed>
<List.Icon name='users' size='large' verticalAlign='middle' /> {groups.map(g =>
<List.Content> <List.Item key={g._id}>
<List.Header as={Link} to={`/groups/${g._id}`}>{g.name}</List.Header> <List.Icon name='users' size='large' verticalAlign='middle' />
<List.Description>{utils.isGroupAdmin(user, g) ? 'Administrator' : 'Member'}</List.Description> <List.Content>
</List.Content> <List.Header as={Link} to={`/groups/${g._id}`}>{g.name}</List.Header>
</List.Item> <List.Description>{utils.isGroupAdmin(user, g) ? 'Administrator' : 'Member'}</List.Description>
)} </List.Content>
</List> </List.Item>
<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/> </List>
</Card.Content> :
</Card> <Card.Description>
: Groups enable you to join or build communities of weavers and makers with similar interests.
<Message> </Card.Description>
<Message.Header>Groups</Message.Header> )
<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' /> <Divider hidden />
</Message> <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' />
@ -54,6 +62,25 @@ function MarketingHome({ onRegisterClicked }) {
</Grid> </Grid>
</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>

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