Compare commits
17 Commits
92860ea082
...
a3a04fa8e9
Author | SHA1 | Date | |
---|---|---|---|
a3a04fa8e9 | |||
d77dbc12ab | |||
cf9601de90 | |||
f36694c54e | |||
8473d2c480 | |||
00946f92d9 | |||
41f8dee19e | |||
4a6c96edb5 | |||
46be02067f | |||
8f498cfe1c | |||
369fd67101 | |||
a2128c7b35 | |||
0b697d22dc | |||
9fd8ea9755 | |||
4c899c2309 | |||
77fc0a502b | |||
781d3e23dd |
@ -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']}
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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']))
|
||||
|
113
api/api/users.py
113
api/api/users.py
@ -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}
|
||||
|
@ -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')
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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();
|
||||
}
|
77
web/src/components/includes/Feed.jsx
Normal file
77
web/src/components/includes/Feed.jsx
Normal 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>
|
||||
);
|
||||
}
|
31
web/src/components/includes/FollowButton.jsx
Normal file
31
web/src/components/includes/FollowButton.jsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 && <>
|
||||
|
@ -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'>
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
Loading…
Reference in New Issue
Block a user