study chapter manager WIP

pull/1834/head
Thibault Duplessis 2016-04-24 16:15:18 +07:00
parent c4f9dc5e6a
commit 5c1ddd8f9a
15 changed files with 390 additions and 188 deletions

View File

@ -16,22 +16,24 @@ object Study extends LilaController {
def show(id: String) = Open { implicit ctx =>
OptionFuResult(env.api byIdWithChapter id) {
case lila.study.Study.WithChapter(study, chapter) =>
val setup = chapter.setup
val initialFen = chapter.root.fen
val pov = UserAnalysis.makePov(initialFen.value.some, setup.variant)
Env.round.jsonView.userAnalysisJson(pov, ctx.pref, setup.orientation, owner = false) zip
Env.chat.api.userChat.find(study.id) zip
env.version(id) map {
case ((baseData, chat), sVersion) =>
import lila.socket.tree.Node.nodeJsonWriter
val analysis = baseData ++ Json.obj(
"tree" -> lila.study.TreeBuilder(chapter.root))
val data = lila.study.JsonView.JsData(
study = lila.study.JsonView.study(study),
analysis = analysis,
chat = lila.chat.JsonView(chat))
Ok(html.study.show(study, data, sVersion))
}
env.chapterRepo.orderedMetadataByStudy(study.id) flatMap { chapters =>
val setup = chapter.setup
val initialFen = chapter.root.fen
val pov = UserAnalysis.makePov(initialFen.value.some, setup.variant)
Env.round.jsonView.userAnalysisJson(pov, ctx.pref, setup.orientation, owner = false) zip
Env.chat.api.userChat.find(study.id) zip
env.version(id) map {
case ((baseData, chat), sVersion) =>
import lila.socket.tree.Node.nodeJsonWriter
val analysis = baseData ++ Json.obj(
"tree" -> lila.study.TreeBuilder(chapter.root))
val data = lila.study.JsonView.JsData(
study = lila.study.JsonView(study, chapters),
analysis = analysis,
chat = lila.chat.JsonView(chat))
Ok(html.study.show(study, data, sVersion))
}
}
} map NoCache
}

View File

@ -22,7 +22,7 @@ socketVersion: @socketVersion
}
@side = {
<div class="side_box">
<div class="side_box study_box">
@base.spinner()
</div>
}

View File

@ -113,6 +113,7 @@ private object BSONHandlers {
private implicit val ChapterSetupBSONHandler = Macros.handler[Chapter.Setup]
implicit val ChapterBSONHandler = Macros.handler[Chapter]
implicit val ChapterMetadataBSONHandler = Macros.handler[Chapter.Metadata]
private implicit val ChaptersMap = MapDocument.MapHandler[Chapter]

View File

@ -35,6 +35,11 @@ object Chapter {
variant: Variant,
orientation: Color)
case class Metadata(
_id: Chapter.ID,
name: String,
setup: Chapter.Setup)
val idSize = 8
def makeId = scala.util.Random.alphanumeric take idSize mkString

View File

@ -6,12 +6,25 @@ import scala.concurrent.duration._
import lila.db.dsl._
private final class ChapterRepo(coll: Coll) {
final class ChapterRepo(coll: Coll) {
import BSONHandlers._
def byId(id: Chapter.ID): Fu[Option[Chapter]] = coll.byId[Chapter](id)
def orderedMetadataByStudy(studyId: Study.ID): Fu[List[Chapter.Metadata]] =
coll.find(
$doc("studyId" -> studyId),
$doc("root" -> false)
).sort($sort asc "order").list[Chapter.Metadata](64)
def nextOrderByStudy(studyId: Study.ID): Fu[Int] =
coll.primitiveOne[Int](
$doc("studyId" -> studyId),
$sort desc "order",
"order"
) map { order => ~order + 1 }
def exists(id: Chapter.ID) = coll.exists($id(id))
def insert(s: Chapter): Funit = coll.insert(s).void

View File

@ -69,7 +69,7 @@ final class Env(
}
private lazy val studyRepo = new StudyRepo(coll = db(CollectionStudy))
private lazy val chapterRepo = new ChapterRepo(coll = db(CollectionChapter))
lazy val chapterRepo = new ChapterRepo(coll = db(CollectionChapter))
}
object Env {

View File

@ -10,7 +10,8 @@ import lila.socket.Socket.Uid
object JsonView {
def study(s: Study) = studyWrites writes s
def apply(study: Study, chapters: List[Chapter.Metadata]) =
studyWrites.writes(study) ++ Json.obj("chapters" -> chapters)
private implicit val uciWrites: Writes[Uci] = Writes[Uci] { u =>
JsString(u.uci)
@ -70,6 +71,12 @@ object JsonView {
private implicit val variantWrites = Writes[chess.variant.Variant] { v => JsString(v.key) }
private implicit val chapterSetupWrites = Json.writes[Chapter.Setup]
private[study] implicit val chapterWrites = Json.writes[Chapter]
private[study] implicit val chapterMetadataWrites = OWrites[Chapter.Metadata] { c =>
Json.obj(
"id" -> c._id,
"name" -> c.name,
"setup" -> c.setup)
}
private implicit val studyWrites = OWrites[Study] { s =>
Json.obj(

View File

@ -51,6 +51,8 @@ private final class Socket(
case ReloadMembers(members) => notifyVersion("members", members, Messadata())
case ReloadChapters(chapters) => notifyVersion("chapters", chapters, Messadata())
case ReloadShapes(shapes, uid) => notifyVersion("shapes", Json.obj(
"s" -> shapes,
"w" -> who(uid)
@ -64,18 +66,16 @@ private final class Socket(
case ReloadUid(uid) => notifyUid("reload", JsNull)(uid)
case PingVersion(uid, v) => {
case PingVersion(uid, v) =>
ping(uid)
timeBomb.delay
withMember(uid) { m =>
history.since(v).fold(resync(m))(_ foreach sendMessage(m))
}
}
case Broom => {
case Broom =>
broom
if (timeBomb.boom) self ! PoisonPill
}
case GetVersion => sender ! history.version
@ -129,6 +129,7 @@ private object Socket {
case class SetPath(position: Position.Ref, uid: Uid)
case class ReloadMembers(members: StudyMembers)
case class ReloadShapes(shapes: List[Shape], uid: Uid)
case class ReloadChapters(chapters: List[Chapter.Metadata])
case class Messadata(trollish: Boolean = false)
case object NotifyCrowd

View File

@ -120,6 +120,13 @@ private[study] final class SocketHandler(
api.setShapes(userId, studyId, shapes, uid)
}
}
case ("addChapter", o) if owner => for {
byUserId <- member.userId
d <- o obj "d"
name <- d str "name"
} api.addChapter(byUserId, studyId, name)
}
private def reading[A](o: JsValue)(f: A => Unit)(implicit reader: Reads[A]): Unit =

View File

@ -62,7 +62,7 @@ final class StudyApi(
def addNode(studyId: Study.ID, position: Position.Ref, node: Node, uid: Uid) = sequenceStudyWithChapter(studyId) {
case Study.WithChapter(study, chapter) => Contribute(node.by, study) {
chapter.addNode(position.path, node) match {
case None => funit >>- reloadUid(study, uid)
case None => fufail(s"Invalid addNode $position $node") >>- reloadUid(study, uid)
case Some(newChapter) =>
chapterRepo.update(newChapter) >>
studyRepo.setPosition(study.id, position + node) >>-
@ -121,6 +121,20 @@ final class StudyApi(
} >>- reloadShapes(study, uid)
}
def addChapter(byUserId: User.ID, studyId: Study.ID, name: String) = sequenceStudy(studyId) { study =>
(study isOwner byUserId) ?? {
chapterRepo.nextOrderByStudy(study.id) flatMap { order =>
val chapter = Chapter.make(
studyId = study.id,
name = name,
setup = Chapter.Setup(none, chess.variant.Standard, chess.White),
root = Node.Root.default,
order = order)
chapterRepo.insert(chapter) >>- reloadChapters(study)
}
}
}
private def reloadUid(study: Study, uid: Uid) =
sendTo(study.id, Socket.ReloadUid(uid))
@ -131,6 +145,11 @@ final class StudyApi(
}
}
private def reloadChapters(study: Study) =
chapterRepo.orderedMetadataByStudy(study.id).foreach { chapters =>
sendTo(study.id, Socket.ReloadChapters(chapters))
}
private def reloadShapes(study: Study, uid: Uid) =
studyRepo.getShapes(study.id).foreach { shapes =>
sendTo(study.id, Socket.ReloadShapes(shapes, uid))

View File

@ -1,23 +1,43 @@
.members .member {
.study_box .spinner {
margin: 20px auto;
}
.study_box .tabs {
display: flex;
-moz-user-select: none;
-webkit-user-select: none;
}
.study_box .tabs a {
text-transform: uppercase;
display: block;
width: 100%;
text-align: center;
padding: 5px 0;
background: #fff;
}
.study_box .tabs a.active {
border-bottom: 3px solid #3893E8;
}
.study_box .list .elem {
display: flex;
justify-content: space-between;
-moz-user-select: none;
-webkit-user-select: none;
}
.member .user_link {
.study_box .elem .user_link {
font-size: 1.2em;
overflow: hidden;
text-overflow: ellipsis;
line-height: 16px;
margin-left: 5px;
}
.members .left,
.members .right {
.study_box .list .left,
.study_box .list .right {
display: flex;
align-items: center;
}
.members .status,
.members .action {
.study_box .list .status,
.study_box .list .action {
display: block;
width: 36px;
height: 36px;
@ -25,74 +45,75 @@
text-align: center;
padding: 0 2px;
}
.members .status i,
.members .action i {
.study_box .list .status i,
.study_box .list .action i {
font-size: 14px;
opacity: 0.4;
transition: opacity 0.13s;
}
.members .action {
.study_box .list .action {
cursor: pointer;
}
.members .action.follow i::before {
font-size: 1.5em;
}
.members > div:hover .action i {
.study_box .list > div:hover .action i {
opacity: 1;
}
.members .status.contrib i {
.study_box .members .status.contrib i {
opacity: 0.7;
}
.members .status {
.study_box .members .status {
transition: 3s;
}
.members .status i {
.study_box .members .status i {
transition: 3s;
}
.members .status.active {
.study_box .members .status.active {
transition: none;
}
.members .member:nth-child(4n-3) .status.active {
.study_box .members .member:nth-child(4n-3) .status.active {
background: #42a5f5;
}
.members .member:nth-child(4n-2) .status.active {
.study_box .members .member:nth-child(4n-2) .status.active {
background: #f44336;
}
.members .member:nth-child(4n-1) .status.active {
.study_box .members .member:nth-child(4n-1) .status.active {
background: #fdd835;
}
.members .member:nth-child(4n-0) .status.active {
.study_box .members .member:nth-child(4n-0) .status.active {
background: #4caf50;
}
.members .status.active i {
.study_box .members .status.active i {
transition: none;
opacity: 1;
color: #fff;
}
.members .invite input {
.study_box .list_input {
width: 214px;
border: none;
border-top: 1px solid #ccc;
background: white;
padding: 3px 5px;
}
.members div.confing,
.members div.config {
.study_box .list div.confing,
.study_box .list div.config {
background: #fff;
}
.members div.config {
.study_box .list div.config {
padding: 5px 10px 15px 10px;
}
.members div.config .role {
.study_box .list div.config .role {
line-height: 22px;
}
.members div.config .role label {
.study_box .list div.config .role label {
cursor: pointer;
}
.members div.config .switch {
.study_box .list div.config .switch {
float: left;
margin-right: 10px;
}
.members div.config .kick {
.study_box .list div.config .kick {
margin-top: 15px;
}
.study_box .chapters .status i {
color: #3893E8;
opacity: 1;
}

View File

@ -0,0 +1,93 @@
var m = require('mithril');
var classSet = require('chessground').util.classSet;
module.exports = {
ctrl: function(chapters, send) {
var vm = {
confing: null // which chapter is being configured by us
};
return {
vm: vm,
list: function() {
return chapters;
},
set: function(cs) {
chapters = cs;
},
add: function(name) {
send("addChapter", {
name: name
});
}
};
},
view: function(ctrl) {
var ownage = ctrl.members.isOwner();
var configButton = function(chapter, confing) {
if (ownage) return m('span.action.config', {
onclick: function(e) {
ctrl.chapters.vm.confing = confing ? null : chapter.id;
}
}, m('i', {
'data-icon': '%'
}));
};
var chapterConfig = function(chapter) {
return m('div.config', [
"config"
]);
};
var create = function() {
return m('div.create', [
m('input', {
class: 'list_input',
config: function(el, isUpdate) {
if (isUpdate) return;
$(el).keypress(function(e) {
if (e.which == 10 || e.which == 13) ctrl.chapters.add($(this).val());
})
},
placeholder: 'Add a new chapter'
})
]);
};
return m('div', {
class: 'list chapters' + (ownage ? ' ownage' : '')
}, [
ctrl.chapters.list().map(function(chapter) {
var confing = ctrl.chapters.vm.confing === chapter.id;
var active = ctrl.position().chapterId === chapter.id;
var attrs = {
class: classSet({
elem: true,
chapter: true,
active: active,
confing: confing
})
};
return [
m('div', attrs, [
m('div.left', [
m('span.status', m('i', {
'data-icon': active ? 'J' : 'K'
})),
chapter.name
]),
m('div.right', [
configButton(chapter, confing)
])
]),
confing ? chapterConfig(chapter) : null
];
}),
ownage ? create() : null
]);
}
};

View File

@ -1,7 +1,9 @@
var m = require('mithril');
var partial = require('chessground').util.partial;
var throttle = require('../util').throttle;
var storedProp = require('../util').storedProp;
var memberCtrl = require('./studyMembers').ctrl;
var chapterCtrl = require('./studyChapters').ctrl;
module.exports = {
// data.position.path represents the server state
@ -10,10 +12,15 @@ module.exports = {
var send = ctrl.socket.send;
var members = memberCtrl(data.members, ctrl.userId, data.ownerId);
var members = memberCtrl(data.members, ctrl.userId, data.ownerId, send);
var chapters = chapterCtrl(data.chapters, send);
var sri = lichess.StrongSocket.sri;
var vm = {
tab: storedProp('study.tab', 'members')
};
function addChapterId(req) {
req.chapterId = data.position.chapterId;
return req;
@ -41,6 +48,8 @@ module.exports = {
return {
data: data,
members: members,
chapters: chapters,
vm: vm,
position: function() {
return data.position;
},
@ -62,23 +71,6 @@ module.exports = {
path: path
}));
},
setRole: function(id, role) {
send("setRole", {
userId: id,
role: role
});
setTimeout(function() {
members.vm.confing = null;
m.redraw();
}, 400);
},
invite: function(username) {
send("invite", username);
},
kick: function(id) {
send("kick", id);
vm.memberConfig = null;
},
onShowGround: function() {
updateShapes();
},
@ -132,6 +124,10 @@ module.exports = {
members.set(d);
m.redraw();
},
chapters: function(d) {
chapters.set(d);
m.redraw();
},
shapes: function(d) {
members.setActive(d.w.u);
data.shapes = d.s;

View File

@ -1,4 +1,5 @@
var m = require('mithril');
var classSet = require('chessground').util.classSet;
function memberActivity(onIdle) {
var timeout;
@ -16,7 +17,7 @@ function memberActivity(onIdle) {
};
module.exports = {
ctrl: function(members, myId, ownerId) {
ctrl: function(members, myId, ownerId, send) {
var vm = {
confing: null // which user is being configured by us
@ -55,6 +56,23 @@ module.exports = {
canContribute: function() {
return myMember() && myMember().role === 'w';
},
setRole: function(id, role) {
send("setRole", {
userId: id,
role: role
});
setTimeout(function() {
vm.confing = null;
m.redraw();
}, 400);
},
invite: function(username) {
send("invite", username);
},
kick: function(id) {
send("kick", id);
vm.confing = null;
},
ordered: function() {
return Object.keys(members).map(function(id) {
return members[id];
@ -63,5 +81,119 @@ module.exports = {
});
},
};
},
view: function(ctrl) {
var ownage = ctrl.members.isOwner();
var username = function(member) {
var u = member.user;
return m('span.user_link.ulpt', {
'data-href': '/@/' + u.name
}, (u.title ? u.title + ' ' : '') + u.name);
};
var statusIcon = function(member) {
var contrib = member.role === 'w';
return m('span', {
class: classSet({
contrib: contrib,
active: ctrl.members.isActive(member.user.id),
status: true,
'hint--top': true
}),
'data-hint': contrib ? 'Contributor' : 'Viewer',
}, m('i', {
'data-icon': contrib ? '' : 'v'
}));
};
var configButton = function(member, confing) {
if (!ownage || member.user.id === ctrl.members.myId) return null;
return m('span.action.config', {
onclick: function(e) {
ctrl.members.vm.confing = confing ? null : member.user.id;
}
}, m('i', {
'data-icon': '%'
}));
};
var invite = function() {
return m('div.invite', [
m('input', {
class: 'list_input',
config: function(el, isUpdate) {
if (isUpdate) return;
lichess.userAutocomplete($(el), {
onSelect: function(v) {
ctrl.members.invite(v);
}
});
},
placeholder: 'Invite someone'
})
]);
};
var memberConfig = function(member) {
return m('div.config', [
(function(id) {
return m('div.role', [
m('div.switch', [
m('input', {
id: id,
class: 'cmn-toggle cmn-toggle-round',
type: 'checkbox',
checked: member.role === 'w',
onchange: function(e) {
ctrl.members.setRole(member.user.id, e.target.checked ? 'w' : 'r');
}
}),
m('label', {
'for': id
})
]),
m('label', {
'for': id
}, 'Contributor')
]);
})('member-role'),
m('div.kick', m('a.button.text[data-icon=L]', {
onclick: function() {
if (confirm('Kick ' + member.user.name + ' out of the study?'))
ctrl.members.kick(member.user.id);
}
}, 'Kick from this study'))
]);
};
return m('div', {
class: 'list members' + (ownage ? ' ownage' : '')
}, [
ctrl.members.ordered().map(function(member) {
var confing = ctrl.members.vm.confing === member.user.id;
var attrs = {
class: classSet({
elem: true,
member: true,
confing: confing
})
};
return [
m('div', attrs, [
m('div.left', [
statusIcon(member),
username(member)
]),
m('div.right', [
configButton(member, confing)
])
]),
confing ? memberConfig(member) : null
];
}),
ownage ? invite() : null
]);
}
};

View File

@ -1,125 +1,30 @@
var m = require('mithril');
var partial = require('chessground').util.partial;
var classSet = require('chessground').util.classSet;
var memberView = require('./studyMembers').view;
var chapterView = require('./studyChapters').view;
module.exports = function(ctrl) {
var ownage = ctrl.members.isOwner();
var username = function(member) {
var u = member.user;
return m('span.user_link.ulpt', {
'data-href': '/@/' + u.name
}, (u.title ? u.title + ' ' : '') + u.name);
var makeTab = function(key, name) {
return m('a', {
class: ctrl.vm.tab() === key ? 'active' : '',
onclick: partial(ctrl.vm.tab, key),
}, 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 tabs = m('div.tabs', [
makeTab('members', 'Members'),
makeTab('chapters', 'Chapters'),
makeTab('settings', 'Settings')
]);
var statusIcon = function(member) {
var contrib = member.role === 'w';
return m('span', {
class: classSet({
contrib: contrib,
active: ctrl.members.isActive(member.user.id),
status: true,
'hint--top': true
}),
'data-hint': contrib ? 'Contributor' : 'Viewer',
}, m('i', {
'data-icon': contrib ? '' : 'v'
}));
};
var panel;
if (ctrl.vm.tab() === 'members') panel = memberView(ctrl);
else if (ctrl.vm.tab() === 'chapters') panel = chapterView(ctrl);
var configButton = function(member, confing) {
if (!ownage || member.user.id === ctrl.members.myId) return null;
return m('span.action.config', {
onclick: function(e) {
ctrl.members.vm.confing = confing ? null : member.user.id;
}
}, m('i', {
'data-icon': '%'
}));
};
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', [
(function(id) {
return m('div.role', [
m('div.switch', [
m('input', {
id: id,
class: 'cmn-toggle cmn-toggle-round',
type: 'checkbox',
checked: member.role === 'w',
onchange: function(e) {
ctrl.setRole(member.user.id, e.target.checked ? 'w' : 'r');
}
}),
m('label', {
'for': id
})
]),
m('label', {
'for': id
}, 'Contributor')
]);
})('member-role'),
m('div.kick', m('a.button.text[data-icon=L]', {
onclick: function() {
if (confirm('Kick ' + member.user.name + ' out of the study?'))
ctrl.kick(member.user.id);
}
}, 'Kick from this study'))
]);
};
return m('div', {
class: 'members' + (ownage ? ' ownage' : '')
},
ctrl.members.ordered().map(function(member) {
var confing = ctrl.members.vm.confing === member.user.id;
var attrs = {
class: classSet({
member: true,
confing: confing
})
};
return [
m('div', attrs, [
m('div.left', [
statusIcon(member),
username(member)
]),
m('div.right', [
configButton(member, confing)
])
]),
confing ? memberConfig(member) : null
];
}),
ownage ? invite() : null);
return [
tabs,
panel
];
};