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']}):
|
for project in db.projects.find({'user': user['_id']}):
|
||||||
db.objects.remove({'project': project['_id']})
|
db.objects.remove({'project': project['_id']})
|
||||||
db.projects.remove({'_id': 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']})
|
db.users.remove({'_id': user['_id']})
|
||||||
return {'deletedUser': user['_id']}
|
return {'deletedUser': user['_id']}
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ from util import database, wif, util, mail
|
|||||||
from api import uploads
|
from api import uploads
|
||||||
|
|
||||||
APP_NAME = os.environ.get('APP_NAME')
|
APP_NAME = os.environ.get('APP_NAME')
|
||||||
|
APP_URL = os.environ.get('APP_URL')
|
||||||
|
|
||||||
def delete(user, id):
|
def delete(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
@ -21,9 +22,13 @@ def delete(user, id):
|
|||||||
|
|
||||||
def get(user, id):
|
def get(user, id):
|
||||||
db = database.get_db()
|
db = database.get_db()
|
||||||
obj = db.objects.find_one(ObjectId(id))
|
obj = db.objects.find_one({'_id': ObjectId(id)})
|
||||||
if not obj:
|
if not obj: raise util.errors.NotFound('Object not found')
|
||||||
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
|
return obj
|
||||||
|
|
||||||
def copy_to_project(user, id, project_id):
|
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'])
|
original_project = db.projects.find_one(obj['project'])
|
||||||
if not original_project:
|
if not original_project:
|
||||||
raise util.errors.NotFound('Project not found')
|
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')
|
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))
|
target_project = db.projects.find_one(ObjectId(project_id))
|
||||||
if not target_project or target_project['user'] != user['_id']:
|
if not target_project or target_project['user'] != user['_id']:
|
||||||
raise util.errors.Forbidden('You don\'t own the target project')
|
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))
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
if not obj: raise util.errors.NotFound('Object not found')
|
if not obj: raise util.errors.NotFound('Object not found')
|
||||||
project = db.projects.find_one(obj['project'])
|
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')
|
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:
|
try:
|
||||||
output = wif.dumps(obj).replace('\n', '\\n')
|
output = wif.dumps(obj).replace('\n', '\\n')
|
||||||
return {'wif': output}
|
return {'wif': output}
|
||||||
@ -64,8 +73,10 @@ def get_pdf(user, id):
|
|||||||
obj = db.objects.find_one(ObjectId(id))
|
obj = db.objects.find_one(ObjectId(id))
|
||||||
if not obj: raise util.errors.NotFound('Object not found')
|
if not obj: raise util.errors.NotFound('Object not found')
|
||||||
project = db.projects.find_one(obj['project'])
|
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')
|
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:
|
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 = 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()
|
response.raise_for_status()
|
||||||
@ -130,8 +141,16 @@ def create_comment(user, id, data):
|
|||||||
return comment
|
return comment
|
||||||
|
|
||||||
def get_comments(user, id):
|
def get_comments(user, id):
|
||||||
|
id = ObjectId(id)
|
||||||
db = database.get_db()
|
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))
|
user_ids = list(map(lambda c:c['user'], comments))
|
||||||
users = list(db.users.find({'_id': {'$in': user_ids}}, {'username': 1, 'avatar': 1}))
|
users = list(db.users.find({'_id': {'$in': user_ids}}, {'username': 1, 'avatar': 1}))
|
||||||
for comment in comments:
|
for comment in comments:
|
||||||
|
@ -68,6 +68,8 @@ def discover(user, count = 3):
|
|||||||
for u in all_users:
|
for u in all_users:
|
||||||
if 'avatar' in u:
|
if 'avatar' in u:
|
||||||
u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(u['_id'], u['avatar']))
|
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)
|
users.append(u)
|
||||||
if len(users) >= count: break
|
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))
|
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:
|
for object in objects:
|
||||||
object['projectObject'] = project_map.get(object['project'])
|
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:
|
for a in authors:
|
||||||
if 'avatar' in a:
|
if 'avatar' in a:
|
||||||
a['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(a['_id'], a['avatar']))
|
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
|
from api import uploads
|
||||||
|
|
||||||
def me(user):
|
def me(user):
|
||||||
|
db = database.get_db()
|
||||||
return {
|
return {
|
||||||
'_id': user['_id'],
|
'_id': user['_id'],
|
||||||
'username': user['username'],
|
'username': user['username'],
|
||||||
@ -17,6 +18,7 @@ def me(user):
|
|||||||
'finishedTours': user.get('completedTours', []) + user.get('skippedTours', []),
|
'finishedTours': user.get('completedTours', []) + user.get('skippedTours', []),
|
||||||
'isSilverSupporter': user.get('isSilverSupporter'),
|
'isSilverSupporter': user.get('isSilverSupporter'),
|
||||||
'isGoldSupporter': user.get('isGoldSupporter'),
|
'isGoldSupporter': user.get('isGoldSupporter'),
|
||||||
|
'followerCount': db.users.find({'following.user': user['_id']}).count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get(user, username):
|
def get(user, username):
|
||||||
@ -33,6 +35,8 @@ def get(user, username):
|
|||||||
project['fullName'] = fetch_user['username'] + '/' + project['path']
|
project['fullName'] = fetch_user['username'] + '/' + project['path']
|
||||||
if 'avatar' in fetch_user:
|
if 'avatar' in fetch_user:
|
||||||
fetch_user['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(fetch_user['_id']), fetch_user['avatar']))
|
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
|
return fetch_user
|
||||||
|
|
||||||
def update(user, username, data):
|
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 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']))
|
if 'avatar' in u: u['avatarUrl'] = uploads.get_presigned_url('users/{0}/{1}'.format(str(u['_id']), u['avatar']))
|
||||||
projects = []
|
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['owner'] = u
|
||||||
project['fullName'] = u['username'] + '/' + project['path']
|
project['fullName'] = u['username'] + '/' + project['path']
|
||||||
projects.append(project)
|
projects.append(project)
|
||||||
@ -93,3 +100,107 @@ def delete_email_subscription(user, username, subscription):
|
|||||||
db.users.update({'_id': u['_id']}, {'$pull': {'subscriptions.email': subscription}})
|
db.users.update({'_id': u['_id']}, {'$pull': {'subscriptions.email': subscription}})
|
||||||
subs = db.users.find_one(u['_id'], {'subscriptions': 1})
|
subs = db.users.find_one(u['_id'], {'subscriptions': 1})
|
||||||
return {'subscriptions': subs.get('subscriptions', {})}
|
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 == '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))
|
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'])
|
@app.route('/users/<username>/tours/<tour>', methods=['PUT'])
|
||||||
def users_tour(username, tour):
|
def users_tour(username, tour):
|
||||||
status = request.args.get('status', 'completed')
|
status = request.args.get('status', 'completed')
|
||||||
|
@ -25,4 +25,13 @@ export const users = {
|
|||||||
deleteEmailSubscription(username, sub, success, fail) {
|
deleteEmailSubscription(username, sub, success, fail) {
|
||||||
api.authenticatedRequest('DELETE', `/users/${username}/subscriptions/email/${sub}`, null, 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 { 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 { BulletList } from 'react-content-loader'
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
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';
|
||||||
|
import FollowButton from './FollowButton';
|
||||||
|
|
||||||
export default function ExploreCard({ count }) {
|
export default function ExploreCard({ count, asCard }) {
|
||||||
const [highlightProjects, setHighlightProjects] = useState([]);
|
const [highlightProjects, setHighlightProjects] = useState([]);
|
||||||
const [highlightUsers, setHighlightUsers] = useState([]);
|
const [highlightUsers, setHighlightUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.search.discover(count || 3, ({ highlightProjects, highlightUsers }) => {
|
api.search.discover(count || 3, ({ highlightProjects, highlightUsers }) => {
|
||||||
@ -18,11 +26,19 @@ export default function ExploreCard({ count }) {
|
|||||||
setHighlightUsers(highlightUsers);
|
setHighlightUsers(highlightUsers);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [userId]);
|
||||||
|
|
||||||
return (
|
function updateFollowing(updateUserId, following) {
|
||||||
<Card fluid>
|
const newHighlightUsers = Object.assign([], highlightUsers).map(u => {
|
||||||
<Card.Content>
|
if (u._id === updateUserId) return { ...u, following };
|
||||||
|
else return u;
|
||||||
|
});
|
||||||
|
setHighlightUsers(newHighlightUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContent() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
<h4>Discover a project</h4>
|
<h4>Discover a project</h4>
|
||||||
{loading && <BulletList />}
|
{loading && <BulletList />}
|
||||||
{highlightProjects?.length > 0 && <>
|
{highlightProjects?.length > 0 && <>
|
||||||
@ -45,13 +61,29 @@ export default function ExploreCard({ count }) {
|
|||||||
{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'/>
|
<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.Content>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
</>}
|
</>}
|
||||||
</Card.Content>
|
</div>
|
||||||
</Card>
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
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 { 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 { 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 api from '../../api';
|
||||||
import actions from '../../actions';
|
import actions from '../../actions';
|
||||||
import utils from '../../utils/utils.js';
|
import utils from '../../utils/utils.js';
|
||||||
@ -55,6 +55,7 @@ export default function NavBar() {
|
|||||||
if (window.drift) window.drift.reset();
|
if (window.drift) window.drift.reset();
|
||||||
navigate('/');
|
navigate('/');
|
||||||
});
|
});
|
||||||
|
const isSupporter = user?.isSilverSupporter || user?.isGoldSupporter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledNavBar>
|
<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='/' 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' as={Link} to='/explore' name='explore' active={location.pathname === '/explore'} />
|
||||||
<Menu.Item className='above-mobile' active={location.pathname.startsWith('/groups')} name='Groups'>
|
<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>}
|
trigger={<span>Groups</span>}
|
||||||
>
|
>
|
||||||
<Dropdown.Menu>
|
<Dropdown.Menu>
|
||||||
@ -78,6 +79,39 @@ export default function NavBar() {
|
|||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Menu.Item>
|
</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'>
|
<Menu.Menu position='right'>
|
||||||
{isAuthenticated && <>
|
{isAuthenticated && <>
|
||||||
|
@ -15,6 +15,7 @@ import ProjectCard from '../includes/ProjectCard';
|
|||||||
import PatternLoader from '../includes/PatternLoader';
|
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';
|
||||||
|
import Feed from '../includes/Feed';
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const [runJoyride, setRunJoyride] = useState(false);
|
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>
|
<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 fluid className='joyride-groups' style={{opacity: 0.8}}>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
@ -125,22 +126,6 @@ function Home() {
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card>
|
</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>
|
||||||
|
|
||||||
<Grid.Column computer={11} className='joyride-projects'>
|
<Grid.Column computer={11} className='joyride-projects'>
|
||||||
|
@ -18,10 +18,6 @@ export default function Explore() {
|
|||||||
return { objects: state.objects.exploreObjects, page: state.objects.explorePage };
|
return { objects: state.objects.exploreObjects, page: state.objects.explorePage };
|
||||||
});
|
});
|
||||||
|
|
||||||
/*useEffect(() => {
|
|
||||||
if (page < 2) loadMoreExplore();
|
|
||||||
}, []);*/
|
|
||||||
|
|
||||||
function loadMoreExplore() {
|
function loadMoreExplore() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.search.explore(page + 1, data => {
|
api.search.explore(page + 1, data => {
|
||||||
@ -34,7 +30,7 @@ export default function Explore() {
|
|||||||
<Container style={{ marginTop: '40px' }}>
|
<Container style={{ marginTop: '40px' }}>
|
||||||
<Grid stackable>
|
<Grid stackable>
|
||||||
<Grid.Column computer={5} tablet={8}>
|
<Grid.Column computer={5} tablet={8}>
|
||||||
<DiscoverCard count={7} />
|
<DiscoverCard asCard count={7} />
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
<Grid.Column computer={11} tablet={8}>
|
<Grid.Column computer={11} tablet={8}>
|
||||||
<h1>Recent patterns on {utils.appName()}</h1>
|
<h1>Recent patterns on {utils.appName()}</h1>
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Helmet } from 'react-helmet';
|
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 { Link, Outlet, useParams } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
import Avatar from 'boring-avatars';
|
import Avatar from 'boring-avatars';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import utils from '../../../utils/utils';
|
import utils from '../../../utils/utils';
|
||||||
@ -11,6 +12,7 @@ import api from '../../../api';
|
|||||||
|
|
||||||
import BlurrableImage from '../../includes/BlurrableImage';
|
import BlurrableImage from '../../includes/BlurrableImage';
|
||||||
import SupporterBadge from '../../includes/SupporterBadge';
|
import SupporterBadge from '../../includes/SupporterBadge';
|
||||||
|
import FollowButton from '../../includes/FollowButton';
|
||||||
|
|
||||||
function Profile() {
|
function Profile() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -23,6 +25,7 @@ function Profile() {
|
|||||||
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];
|
||||||
return { user, profileUser, errorMessage: state.users.errorMessage };
|
return { user, profileUser, errorMessage: state.users.errorMessage };
|
||||||
});
|
});
|
||||||
|
const userId = user?._id;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -33,7 +36,7 @@ function Profile() {
|
|||||||
dispatch(actions.users.requestFailed(err));
|
dispatch(actions.users.requestFailed(err));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [dispatch, username])
|
}, [dispatch, username, userId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container style={{ marginTop: '40px' }}>
|
<Container style={{ marginTop: '40px' }}>
|
||||||
@ -87,6 +90,16 @@ function Profile() {
|
|||||||
<div style={{marginTop: 10}}><SupporterBadge type='silver' /></div>
|
<div style={{marginTop: 10}}><SupporterBadge type='silver' /></div>
|
||||||
}
|
}
|
||||||
</Card.Content>
|
</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
|
{profileUser.location
|
||||||
&& (
|
&& (
|
||||||
<Card.Content extra textAlign="right">
|
<Card.Content extra textAlign="right">
|
||||||
|
Loading…
Reference in New Issue
Block a user