Compare commits

...

17 Commits

Author SHA1 Message Date
a3a04fa8e9 Catch issue with no onChange on FollowButton component
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-06-04 22:33:48 +01:00
d77dbc12ab Show supporter status in discover cards 2023-06-04 22:29:33 +01:00
cf9601de90 Allow for following from discover view 2023-06-04 22:21:40 +01:00
f36694c54e Display supporter badges in explore mode 2023-06-04 21:53:22 +01:00
8473d2c480 Improved re-render when current user changes in user profiles 2023-06-04 16:55:27 +01:00
00946f92d9 Improved handling of non-logged-in users for following 2023-06-04 16:54:11 +01:00
41f8dee19e Improved security on objects and comments 2023-06-04 16:43:28 +01:00
4a6c96edb5 Improvements to the feed producer and surrounding UI 2023-06-04 16:24:02 +01:00
46be02067f Improved supporter dropdown 2023-06-04 15:38:49 +01:00
8f498cfe1c Move ‘support Treadl’ section to navbar 2023-06-04 15:20:40 +01:00
369fd67101 Improved rendering of Feed on homepage 2023-06-03 13:35:36 +01:00
a2128c7b35 Improved contextual info for the feed 2023-06-03 13:14:31 +01:00
0b697d22dc Render a basic feed 2023-06-02 20:16:11 +01:00
9fd8ea9755 Add a draft feed generation function 2023-06-02 19:50:44 +01:00
4c899c2309 Clean up followings on account deletion 2023-06-02 19:02:28 +01:00
77fc0a502b Switch to using user followings rather than followers 2023-06-02 18:57:00 +01:00
781d3e23dd Basic structure for following users 2023-06-02 18:38:41 +01:00
13 changed files with 365 additions and 45 deletions

View File

@ -155,6 +155,8 @@ def delete(user, password):
for project in db.projects.find({'user': user['_id']}):
db.objects.remove({'project': project['_id']})
db.projects.remove({'_id': project['_id']})
db.comments.remove({'user': user['_id']})
db.users.update_many({'following.user': user['_id']}, {'$pull': {'following': {'user': user['_id']}}})
db.users.remove({'_id': user['_id']})
return {'deletedUser': user['_id']}

View File

@ -5,6 +5,7 @@ from util import database, wif, util, mail
from api import uploads
APP_NAME = os.environ.get('APP_NAME')
APP_URL = os.environ.get('APP_URL')
def delete(user, id):
db = database.get_db()
@ -21,9 +22,13 @@ def delete(user, id):
def get(user, id):
db = database.get_db()
obj = db.objects.find_one(ObjectId(id))
if not obj:
raise util.errors.NotFound('Object not found')
obj = db.objects.find_one({'_id': ObjectId(id)})
if not obj: raise util.errors.NotFound('Object not found')
proj = db.projects.find_one({'_id': obj['project']})
if not proj: raise util.errors.NotFound('Project not found')
owner = user and (user.get('_id') == proj['user'])
if not owner and proj['visibility'] != 'public':
raise util.errors.BadRequest('Forbidden')
return obj
def copy_to_project(user, id, project_id):
@ -33,8 +38,10 @@ def copy_to_project(user, id, project_id):
original_project = db.projects.find_one(obj['project'])
if not original_project:
raise util.errors.NotFound('Project not found')
if not original_project.get('openSource') and not (user and user['_id'] == original_project['user']):
if not original_project.get('openSource') and not (user['_id'] == original_project['user']):
raise util.errors.Forbidden('This project is not open-source')
if original_project.get('visibility') != 'public' and user['_id'] != original_project['user']:
raise util.errors.Forbidden('This project is not public')
target_project = db.projects.find_one(ObjectId(project_id))
if not target_project or target_project['user'] != user['_id']:
raise util.errors.Forbidden('You don\'t own the target project')
@ -51,8 +58,10 @@ def get_wif(user, id):
obj = db.objects.find_one(ObjectId(id))
if not obj: raise util.errors.NotFound('Object not found')
project = db.projects.find_one(obj['project'])
if not project.get('openSource') and not (user and user['_id'] == project['user']):
if not project.get('openSource') and user['_id'] != project['user']:
raise util.errors.Forbidden('This project is not open-source')
if project.get('visibility') != 'public' and user['_id'] != project['user']:
raise util.errors.Forbidden('This project is not public')
try:
output = wif.dumps(obj).replace('\n', '\\n')
return {'wif': output}
@ -64,8 +73,10 @@ def get_pdf(user, id):
obj = db.objects.find_one(ObjectId(id))
if not obj: raise util.errors.NotFound('Object not found')
project = db.projects.find_one(obj['project'])
if not project.get('openSource') and not (user and user['_id'] == project['user']):
if not project.get('openSource') and user['_id'] != project['user']:
raise util.errors.Forbidden('This project is not open-source')
if project.get('visibility') != 'public' and user['_id'] != project['user']:
raise util.errors.Forbidden('This project is not public')
try:
response = requests.get('https://h2io6k3ovg.execute-api.eu-west-1.amazonaws.com/prod/pdf?object=' + id + '&landscape=true&paperWidth=23.39&paperHeight=33.11')
response.raise_for_status()
@ -130,8 +141,16 @@ def create_comment(user, id, data):
return comment
def get_comments(user, id):
id = ObjectId(id)
db = database.get_db()
comments = list(db.comments.find({'object': ObjectId(id)}))
obj = db.objects.find_one({'_id': id}, {'project': 1})
if not obj: raise util.errors.NotFound('Object not found')
proj = db.projects.find_one({'_id': obj['project']}, {'user': 1, 'visibility': 1})
if not proj: raise util.errors.NotFound('Project not found')
is_owner = user and (user.get('_id') == proj['user'])
if not is_owner and proj['visibility'] != 'public':
raise util.errors.Forbidden('This project is private')
comments = list(db.comments.find({'object': id}))
user_ids = list(map(lambda c:c['user'], comments))
users = list(db.users.find({'_id': {'$in': user_ids}}, {'username': 1, 'avatar': 1}))
for comment in comments:

View File

@ -68,6 +68,8 @@ def discover(user, count = 3):
for u in all_users:
if 'avatar' in u:
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
if user:
u['following'] = u['_id'] in list(map(lambda f: f['user'], user.get('following', [])))
users.append(u)
if len(users) >= count: break
@ -89,7 +91,7 @@ def explore(page = 1):
objects = list(db.objects.find({'project': {'$in': all_public_project_ids}, 'name': {'$not': re.compile('untitled pattern', re.IGNORECASE)}, 'preview': {'$exists': True}}, {'project': 1, 'name': 1, 'createdAt': 1, 'preview': 1}).sort('createdAt', pymongo.DESCENDING).skip((page - 1) * per_page).limit(per_page))
for object in objects:
object['projectObject'] = project_map.get(object['project'])
authors = list(db.users.find({'_id': {'$in': list(map(lambda o: o.get('projectObject', {}).get('user'), objects))}}, {'username': 1, 'avatar': 1}))
authors = list(db.users.find({'_id': {'$in': list(map(lambda o: o.get('projectObject', {}).get('user'), objects))}}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}))
for a in authors:
if 'avatar' in a:
a['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(a['_id'], a['avatar']))

View File

@ -4,6 +4,7 @@ from util import database, util
from api import uploads
def me(user):
db = database.get_db()
return {
'_id': user['_id'],
'username': user['username'],
@ -17,6 +18,7 @@ def me(user):
'finishedTours': user.get('completedTours', []) + user.get('skippedTours', []),
'isSilverSupporter': user.get('isSilverSupporter'),
'isGoldSupporter': user.get('isGoldSupporter'),
'followerCount': db.users.find({'following.user': user['_id']}).count(),
}
def get(user, username):
@ -33,6 +35,8 @@ def get(user, username):
project['fullName'] = fetch_user['username'] + '/' + project['path']
if 'avatar' in fetch_user:
fetch_user['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(fetch_user['_id']), fetch_user['avatar']))
if user:
fetch_user['following'] = fetch_user['_id'] in list(map(lambda f: f['user'], user.get('following', [])))
return fetch_user
def update(user, username, data):
@ -72,7 +76,10 @@ def get_projects(user, id):
if not u: raise util.errors.NotFound('User not found')
if 'avatar' in u: u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar']))
projects = []
for project in db.projects.find({'user': ObjectId(id)}):
project_query = {'user': ObjectId(id)}
if not user or not user['_id'] == ObjectId(id):
project_query['visibility'] = 'public'
for project in db.projects.find(project_query):
project['owner'] = u
project['fullName'] = u['username'] + '/' + project['path']
projects.append(project)
@ -93,3 +100,107 @@ def delete_email_subscription(user, username, subscription):
db.users.update({'_id': u['_id']}, {'$pull': {'subscriptions.email': subscription}})
subs = db.users.find_one(u['_id'], {'subscriptions': 1})
return {'subscriptions': subs.get('subscriptions', {})}
def create_follower(user, username):
db = database.get_db()
target_user = db.users.find_one({'username': username.lower()})
if not target_user: raise util.errors.NotFound('User not found')
if target_user['_id'] == user['_id']: raise util.errors.BadRequest('Cannot follow yourself')
follow_object = {
'user': target_user['_id'],
'followedAt': datetime.datetime.utcnow(),
}
db.users.update_one({'_id': user['_id']}, {'$addToSet': {'following': follow_object}})
return follow_object
def delete_follower(user, username):
db = database.get_db()
target_user = db.users.find_one({'username': username.lower()})
if not target_user: raise util.errors.NotFound('User not found')
db.users.update_one({'_id': user['_id']}, {'$pull': {'following': {'user': target_user['_id']}}})
return {'unfollowed': True}
def get_feed(user, username):
db = database.get_db()
if user['username'] != username: raise util.errors.Forbidden('Forbidden')
following_user_ids = list(map(lambda f: f['user'], user.get('following', [])))
following_project_ids = list(map(lambda p: p['_id'], db.projects.find({'user': {'$in': following_user_ids}, 'visibility': 'public'}, {'_id': 1})))
one_year_ago = datetime.datetime.utcnow() - datetime.timedelta(days = 365)
# Fetch the items for the feed
recent_projects = list(db.projects.find({
'_id': {'$in': following_project_ids},
'createdAt': {'$gt': one_year_ago},
'visibility': 'public',
}, {'user': 1, 'createdAt': 1, 'name': 1, 'path': 1, 'visibility': 1}).sort('createdAt', -1).limit(20))
recent_objects = list(db.objects.find({
'project': {'$in': following_project_ids},
'createdAt': {'$gt': one_year_ago}
}, {'project': 1, 'createdAt': 1, 'name': 1}).sort('createdAt', -1).limit(30))
recent_comments = list(db.comments.find({
'user': {'$in': following_user_ids},
'createdAt': {'$gt': one_year_ago}
}, {'user': 1, 'createdAt': 1, 'object': 1, 'content': 1}).sort('createdAt', -1).limit(30))
# Process objects (as don't know the user)
object_project_ids = list(map(lambda o: o['project'], recent_objects))
object_projects = list(db.projects.find({'_id': {'$in': object_project_ids}, 'visibility': 'public'}, {'user': 1}))
for obj in recent_objects:
for proj in object_projects:
if obj['project'] == proj['_id']: obj['user'] = proj.get('user')
# Process comments as don't know the project
comment_object_ids = list(map(lambda c: c['object'], recent_comments))
comment_objects = list(db.objects.find({'_id': {'$in': comment_object_ids}}, {'project': 1}))
for com in recent_comments:
for obj in comment_objects:
if com['object'] == obj['_id']: com['project'] = obj.get('project')
# Prepare the feed items, and sort it
feed_items = []
for p in recent_projects:
p['feedType'] = 'project'
feed_items.append(p)
for o in recent_objects:
o['feedType'] = 'object'
feed_items.append(o)
for c in recent_comments:
c['feedType'] = 'comment'
feed_items.append(c)
feed_items.sort(key=lambda d: d['createdAt'], reverse = True)
feed_items = feed_items[:20]
# Post-process the feed, adding user/project objects
feed_user_ids = set()
feed_project_ids = set()
for f in feed_items:
feed_user_ids.add(f.get('user'))
feed_project_ids.add(f.get('project'))
feed_projects = list(db.projects.find({'_id': {'$in': list(feed_project_ids)}, 'visibility': 'public'}, {'name': 1, 'path': 1, 'user': 1, 'visibility': 1}))
feed_users = list(db.users.find({'$or': [
{'_id': {'$in': list(feed_user_ids)}},
{'_id': {'$in': list(map(lambda p: p['user'], feed_projects))}},
]}, {'username': 1, 'avatar': 1, 'isSilverSupporter': 1, 'isGoldSupporter': 1}))
feed_user_map = {}
feed_project_map = {}
for u in feed_users: feed_user_map[str(u['_id'])] = u
for p in feed_projects: feed_project_map[str(p['_id'])] = p
for f in feed_items:
if f.get('user') and feed_user_map.get(str(f['user'])):
f['userObject'] = feed_user_map.get(str(f['user']))
if f.get('project') and feed_project_map.get(str(f['project'])):
f['projectObject'] = feed_project_map.get(str(f['project']))
if f.get('projectObject', {}).get('user') and feed_user_map.get(str(f['projectObject']['user'])):
f['projectObject']['userObject'] = feed_user_map.get(str(f['projectObject']['user']))
# Filter out orphaned or non-public comments/objects
def filter_func(f):
if f['feedType'] == 'comment' and not f.get('projectObject'):
return False
if f['feedType'] == 'object' and not f.get('projectObject'):
return False
return True
feed_items = list(filter(filter_func, feed_items))
return {'feed': feed_items}

View File

@ -105,6 +105,15 @@ def users_username(username):
if request.method == 'GET': return util.jsonify(users.get(util.get_user(required=False), username))
if request.method == 'PUT': return util.jsonify(users.update(util.get_user(), username, request.json))
@app.route('/users/<username>/feed', methods=['GET'])
def users_feed(username):
if request.method == 'GET': return util.jsonify(users.get_feed(util.get_user(), username))
@app.route('/users/<username>/followers', methods=['POST', 'DELETE'])
def users_followers(username):
if request.method == 'POST': return util.jsonify(users.create_follower(util.get_user(), username))
if request.method == 'DELETE': return util.jsonify(users.delete_follower(util.get_user(), username))
@app.route('/users/<username>/tours/<tour>', methods=['PUT'])
def users_tour(username, tour):
status = request.args.get('status', 'completed')

View File

@ -25,4 +25,13 @@ export const users = {
deleteEmailSubscription(username, sub, success, fail) {
api.authenticatedRequest('DELETE', `/users/${username}/subscriptions/email/${sub}`, null, success, fail);
},
follow(username, success, fail) {
api.authenticatedRequest('POST', `/users/${username}/followers`, null, success, fail);
},
unfollow(username, success, fail) {
api.authenticatedRequest('DELETE', `/users/${username}/followers`, null, success, fail);
},
getFeed(username, success, fail) {
api.authenticatedRequest('GET', `/users/${username}/feed`, null, success, fail);
}
};

View File

@ -2,15 +2,23 @@ import React, { useState, useEffect } from 'react';
import { Card, List, Dimmer } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { BulletList } from 'react-content-loader'
import { useSelector } from 'react-redux';
import UserChip from './UserChip';
import api from '../../api';
import utils from '../../utils/utils.js';
import FollowButton from './FollowButton';
export default function ExploreCard({ count }) {
export default function ExploreCard({ count, asCard }) {
const [highlightProjects, setHighlightProjects] = useState([]);
const [highlightUsers, setHighlightUsers] = useState([]);
const [loading, setLoading] = useState(false);
const { user } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user };
});
const userId = user?._id;
useEffect(() => {
setLoading(true);
api.search.discover(count || 3, ({ highlightProjects, highlightUsers }) => {
@ -18,11 +26,19 @@ export default function ExploreCard({ count }) {
setHighlightUsers(highlightUsers);
setLoading(false);
});
}, []);
}, [userId]);
return (
<Card fluid>
<Card.Content>
function updateFollowing(updateUserId, following) {
const newHighlightUsers = Object.assign([], highlightUsers).map(u => {
if (u._id === updateUserId) return { ...u, following };
else return u;
});
setHighlightUsers(newHighlightUsers);
}
function getContent() {
return (
<div>
<h4>Discover a project</h4>
{loading && <BulletList />}
{highlightProjects?.length > 0 && <>
@ -37,7 +53,7 @@ export default function ExploreCard({ count }) {
)}
</List>
</>}
<h4>Find others on {utils.appName()}</h4>
{loading && <BulletList />}
{highlightUsers?.length > 0 && <>
@ -45,13 +61,29 @@ export default function ExploreCard({ count }) {
{highlightUsers?.map(u =>
<List.Item key={u._id}>
<List.Content>
<UserChip user={u} className='umami--click--discover-user'/>
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
<UserChip user={u} className='umami--click--discover-user'/>
<div>
<FollowButton compact targetUser={u} onChange={f => updateFollowing(u._id, f)} />
</div>
</div>
</List.Content>
</List.Item>
)}
</List>
</>}
</Card.Content>
</Card>
);
</div>
);
}
if (asCard)
return (
<Card fluid>
<Card.Content>
{getContent()}
</Card.Content>
</Card>
);
return getContent();
}

View File

@ -0,0 +1,77 @@
import React, { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Message, Card, Icon } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { BulletList } from 'react-content-loader'
import api from '../../api';
import UserChip from './UserChip';
import DiscoverCard from './DiscoverCard';
export default function Feed() {
const [feed, setFeed] = useState([]);
const [loading, setLoading] = useState(false);
const { user } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user };
});
const username = user?.username;
useEffect(() => {
if (!username) return;
setLoading(true);
api.users.getFeed(username, result => {
setFeed(result.feed);
setLoading(false);
});
}, [username]);
return (
<Card fluid>
<Card.Content style={{maxHeight: 500, overflowY: 'scroll'}}>
<Card.Header style={{marginBottom: 10}}>Recent activity</Card.Header>
{loading &&
<div>
<BulletList />
</div>
}
{!loading && !feed?.length &&
<div>
<p style={{background: 'rgba(0,0,0,0.05)', padding: 5, borderRadius: 5}}><Icon name='feed' /> Your feed is empty. You can <Link to='/explore'>follow others</Link> to stay up-to-date.</p>
<DiscoverCard />
</div>
}
{!loading && feed?.map(item =>
<div key={item._id} style={{display: 'flex', alignItems: 'center', marginBottom: 10}}>
<div style={{marginRight: 10}}>
<UserChip user={item.userObject} avatarOnly />
</div>
<div>
<span style={{marginRight: 5}}><Link to={`/${item.userObject?.username}`}>{item.userObject?.username}</Link></span>
{item.feedType === 'comment' &&
<span>wrote a comment
{item.projectObject?.userObject && item.object &&
<span> on <Link to={`/${item.projectObject.userObject.username}/${item.projectObject.path}/${item.object}`}>an item</Link> in {item.projectObject.name}</span>
}
</span>
}
{item.feedType === 'object' &&
<span>added an item
{item.projectObject?.userObject &&
<span> to <Link to={`/${item.projectObject.userObject.username}/${item.projectObject.path}`}>{item.projectObject.name}</Link></span>
}
</span>
}
{item.feedType === 'project' &&
<span>started a new project
{item.userObject && item.path &&
<span>: <Link to={`/${item.userObject.username}/${item.path}`}>{item.name}</Link></span>
}
</span>
}
</div>
</div>
)}
</Card.Content>
</Card>
);
}

View File

@ -0,0 +1,31 @@
import React from 'react';
import { Button, Icon } from 'semantic-ui-react';
import { useDispatch, useSelector } from 'react-redux';
import actions from '../../actions';
import api from '../../api';
export default function FollowButton({ targetUser, compact, onChange }) {
const dispatch = useDispatch();
const { user } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user };
});
function follow(following) {
if (!user) return;
const f = following ? api.users.follow : api.users.unfollow;
f(targetUser?.username, result => {
dispatch(actions.users.receive({ ...targetUser, following }));
onChange && onChange(following);
}, err => {
toast.error(err.message);
});
}
return (
targetUser.following ?
<Button fluid size={compact ? 'mini': 'small'} basic color='blue' onClick={e => follow(false)}><Icon name='check' /> Following</Button>
:
<Button fluid size={compact ? 'mini': 'small'} color='blue' onClick={e => follow(true)} data-tooltip={user ? null : 'You need to be logged-in to follow someone'}>Follow</Button>
);
}

View File

@ -2,7 +2,7 @@ import React from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { Modal, Menu, Button, Container, Dropdown } from 'semantic-ui-react';
import { Modal, Menu, Button, Container, Dropdown, Popup, Icon, List } from 'semantic-ui-react';
import api from '../../api';
import actions from '../../actions';
import utils from '../../utils/utils.js';
@ -55,6 +55,7 @@ export default function NavBar() {
if (window.drift) window.drift.reset();
navigate('/');
});
const isSupporter = user?.isSilverSupporter || user?.isGoldSupporter;
return (
<StyledNavBar>
@ -65,7 +66,7 @@ export default function NavBar() {
<Menu.Item className='above-mobile' as={Link} to='/' name='home' active={location.pathname === '/'} />
<Menu.Item className='above-mobile' as={Link} to='/explore' name='explore' active={location.pathname === '/explore'} />
<Menu.Item className='above-mobile' active={location.pathname.startsWith('/groups')} name='Groups'>
<Dropdown pointing='top left'
<Dropdown pointing='top left' icon={null}
trigger={<span>Groups</span>}
>
<Dropdown.Menu>
@ -78,6 +79,39 @@ export default function NavBar() {
</Dropdown.Menu>
</Dropdown>
</Menu.Item>
{user && !isSupporter && (import.meta.env.VITE_PATREON_URL || import.meta.env.VITE_KOFI_URL) &&
<Menu.Item className='above-mobile'>
<Popup pointing='top left' on='hover' hoverable
trigger={<div style={{padding: '5px 10px', background: 'rgba(0,0,0,0.03)', borderRadius: 5}}><span role='img' aria-label='Celebrate' style={{marginRight: 5}}>🙌</span> Help {utils.appName()}</div>}
content={
<div>
<h3><Icon name='trophy' />Support {utils.appName()}</h3>
<p>{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.</p>
<List relaxed='very'>
{import.meta.env.VITE_KOFI_URL &&
<List.Item as='a' href={import.meta.env.VITE_KOFI_URL} target='_blank' rel='noopener noreferrer' className='umami--click--kofi-button'>
<List.Icon name='coffee' size='large' verticalAlign='middle' />
<List.Content>
<List.Header as='a'>Buy us a coffee</List.Header>
<List.Description as='a'>One-off or monthly support</List.Description>
</List.Content>
</List.Item>
}
{import.meta.env.VITE_PATREON_URL &&
<List.Item as='a' href={import.meta.env.VITE_PATREON_URL} target='_blank' rel='noopener noreferrer' className='umami--click--patreon-button'>
<List.Icon name='patreon' size='large' verticalAlign='middle' />
<List.Content>
<List.Header as='a'>Join us on Patreon</List.Header>
<List.Description as='a'>You can get a supporter badge on your profile</List.Description>
</List.Content>
</List.Item>
}
</List>
</div>
}
/>
</Menu.Item>
}
<Menu.Menu position='right'>
{isAuthenticated && <>

View File

@ -15,6 +15,7 @@ import ProjectCard from '../includes/ProjectCard';
import PatternLoader from '../includes/PatternLoader';
import Tour from '../includes/Tour';
import DiscoverCard from '../includes/DiscoverCard';
import Feed from '../includes/Feed';
function Home() {
const [runJoyride, setRunJoyride] = useState(false);
@ -89,7 +90,7 @@ function Home() {
<h2><span role="img" aria-label="wave">👋</span> {greeting}{user && <span>, {user.username}</span>}</h2>
<DiscoverCard count={3} />
<Feed />
<Card fluid className='joyride-groups' style={{opacity: 0.8}}>
<Card.Content>
@ -125,22 +126,6 @@ function Home() {
</Card.Content>
</Card>
{(import.meta.env.VITE_PATREON_URL || import.meta.env.VITE_KOFI_URL) &&
<Card fluid style={{opacity: 0.8}}>
<Card.Content>
<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>
<Divider hidden />
{import.meta.env.VITE_KOFI_URL &&
<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 &&
<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>
}
</Card.Content>
</Card>
}
</Grid.Column>
<Grid.Column computer={11} className='joyride-projects'>

View File

@ -18,10 +18,6 @@ export default function Explore() {
return { objects: state.objects.exploreObjects, page: state.objects.explorePage };
});
/*useEffect(() => {
if (page < 2) loadMoreExplore();
}, []);*/
function loadMoreExplore() {
setLoading(true);
api.search.explore(page + 1, data => {
@ -34,7 +30,7 @@ export default function Explore() {
<Container style={{ marginTop: '40px' }}>
<Grid stackable>
<Grid.Column computer={5} tablet={8}>
<DiscoverCard count={7} />
<DiscoverCard asCard count={7} />
</Grid.Column>
<Grid.Column computer={11} tablet={8}>
<h1>Recent patterns on {utils.appName()}</h1>

View File

@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { Loader, Icon, List, Container, Card, Grid, Message } from 'semantic-ui-react';
import { Loader, Icon, List, Container, Card, Grid, Message, Button } from 'semantic-ui-react';
import { Link, Outlet, useParams } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { toast } from 'react-toastify';
import Avatar from 'boring-avatars';
import moment from 'moment';
import utils from '../../../utils/utils';
@ -11,6 +12,7 @@ import api from '../../../api';
import BlurrableImage from '../../includes/BlurrableImage';
import SupporterBadge from '../../includes/SupporterBadge';
import FollowButton from '../../includes/FollowButton';
function Profile() {
const [loading, setLoading] = useState(false);
@ -23,6 +25,7 @@ function Profile() {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user, profileUser, errorMessage: state.users.errorMessage };
});
const userId = user?._id;
useEffect(() => {
setLoading(true);
@ -33,7 +36,7 @@ function Profile() {
dispatch(actions.users.requestFailed(err));
setLoading(false);
});
}, [dispatch, username])
}, [dispatch, username, userId])
return (
<Container style={{ marginTop: '40px' }}>
@ -87,6 +90,16 @@ function Profile() {
<div style={{marginTop: 10}}><SupporterBadge type='silver' /></div>
}
</Card.Content>
{profileUser._id !== user?._id &&
<Card.Content style={{marginTop: 10}}>
<FollowButton targetUser={profileUser} />
</Card.Content>
}
{profileUser._id === user?._id &&
<Card.Content extra textAlign='right'>
<p><Icon name='users' /> You have {user?.followerCount || 0} followers</p>
</Card.Content>
}
{profileUser.location
&& (
<Card.Content extra textAlign="right">