more study wip

pull/1834/head
Thibault Duplessis 2016-04-21 11:42:49 +07:00
parent 051ac122ed
commit a80fe9f04b
14 changed files with 267 additions and 105 deletions

View File

@ -42,7 +42,7 @@ object Study extends LilaController {
studyId = id,
uid = uid,
userId = ctx.userId,
owner = ctx.userId.contains(study.ownerId))
owner = ctx.userId.exists(study.isOwner))
}
}
}

View File

@ -125,8 +125,12 @@ private object BSONHandlers {
def write(x: StudyMember.Role) = BSONString(x.id)
}
private implicit val LightUserBSONHandler = Macros.handler[LightUser]
private implicit val MemberBSONHandler = Macros.handler[StudyMember]
private[study] implicit val MemberBSONHandler = Macros.handler[StudyMember]
private[study] implicit val MemberMapBSONHandler = MapDocument.MapHandler[StudyMember]
private[study] implicit val MembersBSONHandler = new BSONHandler[BSONDocument, StudyMembers] {
def read(b: BSONDocument) = StudyMembers(MemberMapBSONHandler read b)
def write(x: StudyMembers) = MemberMapBSONHandler write x.members
}
implicit val StudyBSONHandler = Macros.handler[Study]
}

View File

@ -94,5 +94,9 @@ object JsonView {
}
private[study] implicit val memberWrites: Writes[StudyMember] = Json.writes[StudyMember]
private[study] implicit val membersWrites: Writes[StudyMembers] = Writes[StudyMembers] { m =>
Json toJson m.members
}
case class BiData(study: JsObject, analysis: JsObject)
}

View File

@ -92,5 +92,5 @@ private object Socket {
case class MemberPosition(userId: User.ID, position: Position.Ref)
case class AddNode(position: Position.Ref, node: Node)
case class DelNode(position: Position.Ref)
case class ReloadMembers(members: MemberMap)
case class ReloadMembers(members: StudyMembers)
}

View File

@ -27,7 +27,7 @@ private[study] final class SocketHandler(
join = Socket.Join(uid = uid, userId = userId, owner = owner)
handler Handler(hub, socket, uid, join, userId) {
case Socket.Connected(enum, member) =>
(controller(socket, studyId, uid, member), enum, member)
(controller(socket, studyId, uid, member, owner = owner), enum, member)
}
} yield handler.some
@ -45,7 +45,8 @@ private[study] final class SocketHandler(
socket: ActorRef,
studyId: Study.ID,
uid: String,
member: Socket.Member): Handler.Controller = {
member: Socket.Member,
owner: Boolean): Handler.Controller = {
case ("p", o) => o int "v" foreach { v =>
socket ! PingVersion(uid, v)
}
@ -91,12 +92,21 @@ private[study] final class SocketHandler(
}
}
}
case ("setRole", o) => AnaRateLimit(uid) {
case ("setRole", o) if owner => AnaRateLimit(uid) {
reading[SetRole](o) { d =>
member.userId foreach { userId =>
api.setRole(studyId, userId, d.userId, d.role)
}
}
}
case ("invite", o) if owner => for {
byUserId <- member.userId
username <- o str "d"
} api.invite(studyId, byUserId, username)
case ("kick", o) if owner => for {
byUserId <- member.userId
userId <- o str "d"
} api.kick(studyId, byUserId, userId)
}
}

View File

@ -11,7 +11,7 @@ import lila.user.User
case class Study(
_id: Study.ID,
chapters: ChapterMap,
members: MemberMap,
members: StudyMembers,
ownerId: User.ID,
createdAt: DateTime) {
@ -22,18 +22,14 @@ case class Study(
def orderedChapters: List[(Chapter.ID, Chapter)] =
chapters.toList.sortBy(_._2.order)
def firstChapter = orderedChapters.headOption.map(_._2)
def firstChapterId = orderedChapters.headOption.map(_._1)
def firstChapter = firstChapterId flatMap chapters.get
def location(chapterId: Chapter.ID): Option[Location] =
chapters get chapterId map { Location(this, chapterId, _) }
def nextChapterOrder: Int = orderedChapters.lastOption.fold(0)(_._2.order) + 1
def addMember(id: User.ID, member: StudyMember) =
copy(members = members + (id -> member))
def hasMember(id: User.ID) = members contains id
def owner = members get ownerId
def isOwner(id: User.ID) = ownerId == id
@ -52,11 +48,15 @@ object Study {
setup: Chapter.Setup) = {
val chapterId = Chapter.makeId
val chapter = Chapter.make(setup, Node.Root.default, 1)
val owner = StudyMember(user, Position.Ref(chapterId, Path.root), StudyMember.Role.Write)
val owner = StudyMember(
user,
Position.Ref(chapterId, Path.root),
StudyMember.Role.Write,
DateTime.now)
Study(
_id = scala.util.Random.alphanumeric take idSize mkString,
chapters = Map(chapterId -> chapter),
members = Map(user.id -> owner),
members = StudyMembers(Map(user.id -> owner)),
ownerId = user.id,
createdAt = DateTime.now)
}

View File

@ -5,7 +5,7 @@ import akka.actor.ActorRef
import chess.format.{ Forsyth, FEN }
import lila.hub.actorApi.map.Tell
import lila.hub.Sequencer
import lila.user.User
import lila.user.{ User, UserRepo }
final class StudyApi(
repo: StudyRepo,
@ -31,9 +31,12 @@ final class StudyApi(
def locationById(id: Location.Ref.ID): Fu[Option[Location]] =
(Location.Ref parseId id) ?? locationByRef
def setMemberPosition(userId: User.ID, ref: Location.Ref, path: Path) =
repo.setMemberPosition(userId, ref, path) >>-
sendTo(ref.studyId, Socket.MemberPosition(userId, Position.Ref(ref.chapterId, path)))
def setMemberPosition(userId: User.ID, ref: Location.Ref, path: Path) = sequenceLocation(ref) { location =>
(location.study canWrite userId) ?? {
repo.setMemberPosition(userId, ref, path) >>-
sendTo(ref.studyId, Socket.MemberPosition(userId, Position.Ref(ref.chapterId, path)))
}
}
def addNode(ref: Location.Ref, path: Path, node: Node) = sequenceLocation(ref) { location =>
(location.study canWrite node.by) ?? {
@ -66,17 +69,31 @@ final class StudyApi(
}
def setRole(studyId: Study.ID, byUserId: User.ID, userId: User.ID, roleStr: String) = sequenceStudy(studyId) { study =>
(study isOwner byUserId) ?? {
val role = StudyMember.Role.byId.getOrElse(roleStr, StudyMember.Role.Read)
repo.setRole(study, userId, role) >>-
repo.membersById(study.id).foreach {
_ foreach { members =>
sendTo(study.id, Socket.ReloadMembers(members))
}
}
}
val role = StudyMember.Role.byId.getOrElse(roleStr, StudyMember.Role.Read)
repo.setRole(study, userId, role) >>- reloadMembers(study)
}
def invite(studyId: Study.ID, byUserId: User.ID, username: String) = sequenceStudy(studyId) { study =>
UserRepo.named(username).flatMap {
_.filterNot(study.members.contains) ?? { user =>
repo.addMember(study, StudyMember.make(study, user))
}
} >>- reloadMembers(study)
}
def kick(studyId: Study.ID, byUserId: User.ID, userId: User.ID) = sequenceStudy(studyId) { study =>
study.members.contains(userId) ?? {
repo.removeMember(study, userId)
} >>- reloadMembers(study)
}
private def reloadMembers(study: Study) =
repo.membersById(study.id).foreach {
_ foreach { members =>
sendTo(study.id, Socket.ReloadMembers(members))
}
}
private def sequenceRef(refId: Location.Ref.ID)(f: Location.Ref => Funit): Funit =
Location.Ref.parseId(refId) ?? { ref =>
sequence(ref.studyId) {

View File

@ -1,17 +1,29 @@
package lila.study
import org.joda.time.DateTime
import lila.common.LightUser
import lila.user.User
case class StudyMember(
user: LightUser,
position: Position.Ref,
role: StudyMember.Role) {
role: StudyMember.Role,
addedAt: DateTime) {
def canWrite = role == StudyMember.Role.Write
}
object StudyMember {
type MemberMap = Map[User.ID, StudyMember]
def make(study: Study, user: User) = StudyMember(
user = user.light,
position = study.owner.fold(Position.Ref(~study.firstChapterId, Path.root))(_.position),
role = Role.Read,
addedAt = DateTime.now)
sealed abstract class Role(val id: String)
object Role {
case object Read extends Role("r")
@ -19,3 +31,13 @@ object StudyMember {
val byId = List(Read, Write).map { x => x.id -> x }.toMap
}
}
case class StudyMembers(members: StudyMember.MemberMap) {
def +(member: StudyMember) = copy(members = members + (member.user.id -> member))
def contains(userId: User.ID): Boolean = members contains userId
def contains(user: User): Boolean = contains(user.id)
def get = members.get _
}

View File

@ -17,8 +17,8 @@ private final class StudyRepo(coll: Coll) {
def insert(s: Study): Funit = coll.insert(s).void
def membersById(id: Study.ID): Fu[Option[MemberMap]] =
coll.primitiveOne[MemberMap]($id(id), "members")
def membersById(id: Study.ID): Fu[Option[StudyMembers]] =
coll.primitiveOne[StudyMembers]($id(id), "members")
def setChapter(loc: Location) = coll.update(
$id(loc.study.id),
@ -31,6 +31,18 @@ private final class StudyRepo(coll: Coll) {
$set(s"members.$id.position" -> Position.Ref(ref.chapterId, path))
).void
def addMember(study: Study, member: StudyMember): Funit =
coll.update(
$id(study.id),
$set(s"members.${member.user.id}" -> member)
).void
def removeMember(study: Study, userId: User.ID): Funit =
coll.update(
$id(study.id),
$unset(s"members.$userId")
).void
def setRole(study: Study, id: User.ID, role: StudyMember.Role): Funit =
coll.update(
$id(study.id),

View File

@ -6,7 +6,5 @@ package object study extends PackageObject with WithPlay with WithSocket {
private[study] val logger = lila.log("study")
private[study]type MemberMap = Map[lila.user.User.ID, lila.study.StudyMember]
private[study]type ChapterMap = Map[lila.study.Chapter.ID, lila.study.Chapter]
}

View File

@ -238,6 +238,44 @@ lichess.challengeApp = (function() {
return open(confirm('Open in lichess mobile app?') ? 10 : -10);
};
lichess.userAutocomplete = function($input, opts) {
opts = opts || {};
lichess.loadCss('/assets/stylesheets/autocomplete.css');
lichess.loadScript('/assets/javascripts/vendor/typeahead.jquery.min.js').done(function() {
$input.typeahead(null, {
minLength: 2,
hint: true,
highlight: false,
source: function(query, sync, async) {
$.ajax({
url: '/player/autocomplete?term=' + query,
success: function(res) {
// hack to fix typeahead limit bug
if (res.length === 10) res.push(null);
async(res);
}
});
},
limit: 10,
templates: {
empty: '<div class="empty">No player found</div>',
pending: lichess.spinnerHtml,
suggestion: function(a) {
return '<span class="ulpt" data-href="/@/' + a + '">' + a + '</span>';
}
}
}).bind('typeahead:render', function() {
$('body').trigger('lichess.content_loaded');
});
if (opts.focus) $input.focus();
if (opts.onSelect) $input.bind('typeahead:select', function(ev, sel) {
opts.onSelect(sel);
}).keypress(function(e) {
if (e.which == 10 || e.which == 13) opts.onSelect($(this).val());
});
});
};
lichess.parseFen = function($elem) {
if (!$elem || !$elem.jquery) {
$elem = $('.parse_fen');
@ -665,42 +703,14 @@ lichess.challengeApp = (function() {
translateTexts();
$('body').on('lichess.content_loaded', translateTexts);
var userAutocomplete = function($input) {
lichess.loadCss('/assets/stylesheets/autocomplete.css');
lichess.loadScript('/assets/javascripts/vendor/typeahead.jquery.min.js').done(function() {
$input.typeahead(null, {
minLength: 2,
hint: true,
highlight: false,
source: function(query, sync, async) {
$.ajax({
url: '/player/autocomplete?term=' + query,
success: function(res) {
// hack to fix typeahead limit bug
if (res.length === 10) res.push(null);
async(res);
}
});
},
limit: 10,
templates: {
empty: '<div class="empty">No player found</div>',
pending: lichess.spinnerHtml,
suggestion: function(a) {
return '<span class="ulpt" data-href="/@/' + a + '">' + a + '</span>';
}
}
}).bind('typeahead:render', function() {
$('body').trigger('lichess.content_loaded');
}).focus();
});
};
$('input.user-autocomplete').each(function() {
if ($(this).attr('autofocus')) userAutocomplete($(this));
if ($(this).attr('autofocus')) lichess.userAutocomplete($(this), {
focus: 1
});
else $(this).one('focus', function() {
userAutocomplete($(this));
lichess.userAutocomplete($(this), {
focus: 1
});
});
});

View File

@ -1,27 +1,39 @@
.members > div {
.members .member {
display: flex;
justify-content: space-between;
-moz-user-select: none;
-webkit-user-select: none;
}
.members > div > * {
.members.ownage .member {
cursor: pointer;
}
.members .member > * {
padding: 10px;
}
.members .follow {
.members .action {
display: block;
width: 16px;
height: 16px;
cursor: pointer;
opacity: 0.3;
opacity: 0.4;
transition: opacity 0.13s;
background: #fff;
}
.members .follow i::before {
.members .action.empty {
background: #ddd;
opacity: 1;
cursor: default;
}
body.dark .members .action.empty {
background: #333;
}
.members .action i::before {
font-size: 1.5em;
}
.members > div:hover .follow {
.members > div:hover .action {
opacity: 1;
}
.members .following .follow {
.members .following .action {
background: #3893E8;
color: #fff;
opacity: 1;
@ -29,3 +41,10 @@
.members.ownage .role {
cursor: pointer;
}
.members .invite input {
width: 213px;
border: none;
border-top: 1px solid #ccc;
background: white;
padding: 3px 5px;
}

View File

@ -21,15 +21,16 @@ module.exports = {
var vm = {
position: meOrOwner().position,
follow: null
follow: null, // which user is being followed by us
memberConfig: null // which user is being configured by us
};
function joiners() {
var members = [];
for (var id in data.members)
if (id !== data.ownerId)
members.push(data.members[id]);
return members;
function orderedMembers() {
return Object.keys(data.members).map(function(id) {
return data.members[id];
}).sort(function(a, b) {
return a.addedAt > b.addedAt;
});
}
function addChapterId(data) {
@ -54,6 +55,10 @@ module.exports = {
}
follow(data.ownerId);
function invite(username) {
if (ownage) send("invite", username);
}
return {
data: data,
vm: vm,
@ -77,9 +82,7 @@ module.exports = {
path: path
}));
},
orderedMembers: function() {
return [owner()].concat(joiners());
},
orderedMembers: orderedMembers,
follow: follow,
toggleRole: function(userId) {
if (!ownage) return;
@ -90,6 +93,12 @@ module.exports = {
role: next
});
},
invite: function(username) {
send("invite", username);
},
kick: function(userId) {
send("kick", userId);
},
socketHandlers: {
mpos: function(d) {
updateMember(d.u, function(m) {

View File

@ -1,33 +1,90 @@
var m = require('mithril');
var partial = require('chessground').util.partial;
var classSet = require('chessground').util.classSet;
module.exports = function(ctrl) {
var ownage = ctrl.userId === ctrl.data.ownerId;
var username = function(member) {
return m('span.user_link.ulpt', {
'data-href': '/@/' + member.user.name
}, member.user.name);
};
var roleToggle = function(member) {
return m('span.role.hint--top', {
'data-hint': member.role === 'r' ? 'Can read' : 'Can write',
onclick: ownage ? function() {
ctrl.toggleRole(member.user.id);
} : $.noop
}, member.role.toUpperCase());
};
var rightButton = function(member) {
if (member.user.id === ctrl.userId) return m('span.action.empty');
if (member.role !== 'w') return m('span.action.empty');
return m('span.action.follow.hint--top', {
'data-hint': 'Follow/Unfollow',
onclick: function(e) {
e.stopPropagation();
ctrl.follow(member.user.id);
}
}, m('i', {
'data-icon': 'v'
}));
};
var invite = function() {
return m('div.invite', [
m('input', {
config: function(el, isUpdate) {
if (isUpdate) return;
lichess.userAutocomplete($(el), {
onSelect: function(v) {
ctrl.invite(v);
}
});
},
class: 'add_member',
placeholder: 'Invite someone'
})
]);
};
var memberConfig = function(member) {
return m('div.config', [
m('div', 'Contributor'),
m('div', m('a.button[data-icon=L]', {
onclick: function() {
ctrl.kick(member.user.id);
}
}, 'Kick from this study'))
]);
};
return m('div', {
class: 'members' + (ownage ? ' ownage' : '')
},
ctrl.orderedMembers().map(function(member) {
return m('div', {
class: (ctrl.vm.follow === member.user.id ? 'following' : '')
}, [
m('span.ulpt', {
'data-href': '/@/' + member.user.name
}, member.user.name),
m('span.role.hint--top', {
'data-hint': member.role === 'r' ? 'Can read' : 'Can write',
onclick: ownage ? function() {
ctrl.toggleRole(member.user.id);
} : $.noop
}, member.role.toUpperCase()), (member.role !== 'w' || member.user.id === ctrl.userId) ? m('span.follow.empty') : m('span.follow.hint--top', {
'data-hint': 'Follow/Unfollow',
onclick: function() {
ctrl.follow(member.user.id);
}
}, m('i', {
'data-icon': 'v'
}))
])
}));
var confing = ctrl.vm.memberConfig === member.user.id;
var attrs = {
class: classSet({
member: true,
following: ctrl.vm.follow === member.user.id,
confing: confing
})
};
if (ownage) attrs.onclick = function() {
ctrl.vm.memberConfig = confing ? null : member.user.id;
}
return [
m('div', attrs, [
username(member),
rightButton(member),
]),
confing ? memberConfig(member) : null
];
}),
ownage ? invite() : null);
};