textareas fit to content in UserBio and Problems
[vchess.git] / client / src / views / Problems.vue
index 7621fe3..b989fbc 100644 (file)
@@ -1,5 +1,15 @@
 <template lang="pug">
 main
+  input#modalRules.modal(type="checkbox")
+  div#rulesDiv(
+    role="dialog"
+    data-checkbox="modalRules"
+  )
+    .card
+      label.modal-close(for="modalRules")
+      a#variantNameInProblems(:href="'/#/variants/'+game.vname")
+        | {{ game.vname }}
+      div(v-html="rulesContent")
   input#modalNewprob.modal(
     type="checkbox"
     @change="fenFocusIfOpened($event)"
@@ -18,6 +28,7 @@ main
         )
           option(
             v-for="v in [emptyVar].concat(st.variants)"
+            v-if="!v.noProblems"
             :value="v.id"
             :selected="curproblem.vid==v.id"
           )
@@ -31,14 +42,16 @@ main
         )
         #diagram(v-html="curproblem.diag")
       fieldset
-        textarea(
+        textarea.instructions-edit(
           :placeholder="st.tr['Instructions']"
+          @input="adjustHeight('instructions')"
           v-model="curproblem.instruction"
         )
         p(v-html="parseHtml(curproblem.instruction)")
       fieldset
-        textarea(
+        textarea.solution-edit(
           :placeholder="st.tr['Solution']"
+          @input="adjustHeight('solution')"
           v-model="curproblem.solution"
         )
         p(v-html="parseHtml(curproblem.solution)")
@@ -47,16 +60,16 @@ main
   .row(v-if="showOne")
     .col-sm-12.col-md-10.col-md-offset-1.col-lg-8.col-lg-offset-2
       #topPage
-        .button-group(v-if="st.user.id == curproblem.uid")
+        .button-group(v-if="canIedit(curproblem.uid)")
           button(@click="editProblem(curproblem)") {{ st.tr["Edit"] }}
           button(@click="deleteProblem(curproblem)") {{ st.tr["Delete"] }}
         span.vname {{ curproblem.vname }}
         span.uname ({{ curproblem.uname }})
         button.marginleft(@click="backToList()") {{ st.tr["Back to list"] }}
-        button.nomargin(@click="gotoPrevNext($event,curproblem,1)")
-          | {{ st.tr["Previous"] }}
-        button.nomargin(@click="gotoPrevNext($event,curproblem,-1)")
-          | {{ st.tr["Next"] }}
+        button.nomargin(@click="gotoPrevNext(curproblem,1)")
+          | {{ st.tr["Previous_p"] }}
+        button.nomargin(@click="gotoPrevNext(curproblem,-1)")
+          | {{ st.tr["Next_p"] }}
       p.oneInstructions.clickable(
         v-html="parseHtml(curproblem.instruction)"
         @click="curproblem.showSolution=!curproblem.showSolution"
@@ -71,15 +84,17 @@ main
       #controls
         button#newProblem(@click="prepareNewProblem()")
           | {{ st.tr["New problem"] }}
-        label(for="checkboxMine") {{ st.tr["My problems"] }}
-        input#checkboxMine(
-          type="checkbox"
-          v-model="onlyMines"
-        )
+        div#myProblems(v-if="st.user.id > 0")
+          label(for="checkboxMine") {{ st.tr["My problems"] }}
+          input#checkboxMine(
+            type="checkbox"
+            v-model="onlyMine"
+          )
         label(for="selectVariant") {{ st.tr["Variant"] }}
         select#selectVariant(v-model="selectedVar")
           option(
             v-for="v in [emptyVar].concat(st.variants)"
+            v-if="!v.noProblems"
             :value="v.id"
           )
             | {{ v.name }}
@@ -89,14 +104,20 @@ main
           th {{ st.tr["Instructions"] }}
           th {{ st.tr["Number"] }}
         tr(
-          v-for="p in problems"
-          v-show="displayProblem(p)"
+          v-for="p in problems[onlyMine ? 'mine' : 'others']"
+          v-show="onlyMine || !selectedVar || p.vid == selectedVar"
           @click="setHrefPid(p)"
         )
           td {{ p.vname }}
           td {{ firstChars(p.instruction) }}
           td {{ p.id }}
+      button#loadMoreBtn(
+        v-if="hasMore[onlyMine ? 'mine' : 'others']"
+        @click="loadMore(onlyMine ? 'mine' : 'others')"
+      )
+        | {{ st.tr["Load more"] }}
   BaseGame(
+    ref="basegame"
     v-if="showOne"
     :game="game"
   )
@@ -106,7 +127,8 @@ main
 import { store } from "@/store";
 import { ajax } from "@/utils/ajax";
 import { checkProblem } from "@/data/problemCheck";
-import { getDiagram } from "@/utils/printDiagram";
+import params from "@/parameters";
+import { getDiagram, replaceByDiag } from "@/utils/printDiagram";
 import { processModalClick } from "@/utils/modalClick";
 import { ArrayFun } from "@/utils/array";
 import BaseGame from "@/components/BaseGame.vue";
@@ -134,10 +156,18 @@ export default {
       },
       loadedVar: 0, //corresponding to loaded V
       selectedVar: 0, //to filter problems based on variant
-      problems: [],
-      onlyMines: false,
+      problems: { "mine": [], "others": [] },
+      // timestamp of oldest showed problem:
+      cursor: {
+        mine: Number.MAX_SAFE_INTEGER,
+        others: Number.MAX_SAFE_INTEGER
+      },
+      // hasMore == TRUE: a priori there could be more problems to load
+      hasMore: { mine: true, others: true },
+      onlyMine: false,
       showOne: false,
       infoMsg: "",
+      rulesContent: "",
       game: {
         players: [{ name: "Problem" }, { name: "Problem" }],
         mode: "analyze"
@@ -145,51 +175,34 @@ export default {
     };
   },
   created: function() {
-    ajax("/problems", "GET", res => {
-      // Show newest problem first:
-      this.problems = res.problems.sort((p1, p2) => p2.added - p1.added);
-      if (this.st.variants.length > 0)
-        this.problems.forEach(p => this.setVname(p));
-      // Retrieve all problems' authors' names
-      let names = {};
-      this.problems.forEach(p => {
-        if (p.uid != this.st.user.id) names[p.uid] = "";
-        else p.uname = this.st.user.name;
-      });
-      const showOneIfPid = () => {
-        const pid = this.$route.query["id"];
-        if (pid) this.showProblem(this.problems.find(p => p.id == pid));
-      };
-      if (Object.keys(names).length > 0) {
-        ajax("/users", "GET", { ids: Object.keys(names).join(",") }, res2 => {
-          res2.users.forEach(u => {
-            names[u.id] = u.name;
-          });
-          this.problems.forEach(p => {
-            if (!p.uname)
-              p.uname = names[p.uid];
-          });
-          showOneIfPid();
-        });
-      } else showOneIfPid();
-    });
+    const pid = this.$route.query["id"];
+    if (!!pid) this.showProblem(pid);
+    else this.loadMore("others", () => { this.loadMore("mine"); });
   },
   mounted: function() {
-    document
-      .getElementById("newprobDiv")
-      .addEventListener("click", processModalClick);
+    ["rulesDiv","newprobDiv"].forEach(eltName => {
+      document.getElementById(eltName)
+      .addEventListener("click", processModalClick)
+    });
   },
   watch: {
     // st.variants changes only once, at loading from [] to [...]
     "st.variants": function() {
       // Set problems vname (either all are set or none)
-      if (this.problems.length > 0 && this.problems[0].vname == "")
-        this.problems.forEach(p => this.setVname(p));
+      let problems = this.problems["others"].concat(this.problems["mine"]);
+      if (problems.length > 0 && problems[0].vname == "")
+        problems.forEach(p => this.setVname(p));
     },
     $route: function(to) {
       const pid = to.query["id"];
-      if (pid) this.showProblem(this.problems.find(p => p.id == pid));
-      else this.showOne = false;
+      if (!!pid) this.showProblem(pid);
+      else {
+        if (this.cursor["others"] == Number.MAX_SAFE_INTEGER)
+          // Back from a single problem view at initial loading:
+          // problems lists are empty!
+          this.loadMore("others", () => { this.loadMore("mine"); });
+        this.showOne = false;
+      }
     }
   },
   methods: {
@@ -199,9 +212,45 @@ export default {
         document.getElementById("inputFen").focus();
       }
     },
+    adjustHeight: function(elt) {
+      // https://stackoverflow.com/a/48460773
+      let t = document.querySelector("." + elt + "-edit");
+      t.style.height = "";
+      t.style.height = (t.scrollHeight + 3) + "px";
+    },
     setVname: function(prob) {
       prob.vname = this.st.variants.find(v => v.id == prob.vid).name;
     },
+    // Add vname and user names:
+    decorate: function(problems, callback) {
+      if (this.st.variants.length > 0)
+        problems.forEach(p => this.setVname(p));
+      // Retrieve all problems' authors' names
+      let names = {};
+      problems.forEach(p => {
+        if (p.uid != this.st.user.id) names[p.uid] = "";
+        else p.uname = this.st.user.name;
+      });
+      if (Object.keys(names).length > 0) {
+        ajax(
+          "/users",
+          "GET",
+          {
+            data: { ids: Object.keys(names).join(",") },
+            success: (res2) => {
+              res2.users.forEach(u => {
+                names[u.id] = u.name;
+              });
+              problems.forEach(p => {
+                if (!p.uname)
+                  p.uname = names[p.uid];
+              });
+              if (!!callback) callback();
+            }
+          }
+        );
+      } else if (!!callback) callback();
+    },
     firstChars: function(text) {
       let preparedText = text
         // Replace line jumps and <br> by spaces
@@ -240,7 +289,10 @@ export default {
     },
     parseHtml: function(txt) {
       return !txt.match(/<[/a-zA-Z]+>/)
-        ? txt.replace(/\n/g, "<br/>") //no HTML tag
+        ?
+          // No HTML tag
+          txt.replace(/\n\n/g, "<br/><div class='br'></div>")
+             .replace(/\n/g, "<br/>")
         : txt;
     },
     changeVariant: function(prob) {
@@ -248,22 +300,39 @@ export default {
       this.loadVariant(prob.vid, () => {
         // Set FEN if possible (might not be correct yet)
         if (V.IsGoodFen(prob.fen)) this.setDiagram(prob);
+        else prob.diag = "";
       });
     },
     loadVariant: async function(vid, cb) {
       // Condition: vid is a valid variant ID
       this.loadedVar = 0;
       const variant = this.st.variants.find(v => v.id == vid);
-      const vModule = await import("@/variants/" + variant.name + ".js");
-      window.V = vModule.VariantRules;
-      this.loadedVar = vid;
-      cb();
+      await import("@/variants/" + variant.name + ".js")
+      .then((vModule) => {
+        window.V = vModule[variant.name + "Rules"];
+        this.loadedVar = vid;
+        cb();
+      });
+      // (AJAX) Request to get rules content (plain text, HTML)
+      this.rulesContent =
+        require(
+          "raw-loader!@/translations/rules/" +
+          variant.name + "/" +
+          this.st.lang + ".pug"
+        )
+        // Next two lines fix a weird issue after last update (2019-11)
+        .replace(/\\n/g, " ")
+        .replace(/\\"/g, '"')
+        .replace('module.exports = "', "")
+        .replace(/"$/, "")
+        .replace(/(fen:)([^:]*):/g, replaceByDiag);
     },
     trySetDiagram: function(prob) {
       // Problem edit: FEN could be wrong or incomplete,
       // variant could not be ready, or not defined
       if (prob.vid > 0 && this.loadedVar == prob.vid && V.IsGoodFen(prob.fen))
         this.setDiagram(prob);
+      else prob.diag = "";
     },
     setDiagram: function(prob) {
       // Condition: prob.fen is correct and global V is ready
@@ -274,41 +343,70 @@ export default {
       };
       prob.diag = getDiagram(args);
     },
-    displayProblem: function(p) {
-      return (
-        (!this.selectedVar || p.vid == this.selectedVar) &&
-        ((this.onlyMines && p.uid == this.st.user.id) ||
-          (!this.onlyMines && p.uid != this.st.user.id))
-      );
-    },
-    showProblem: function(p) {
-      this.loadVariant(p.vid, () => {
-        // The FEN is already checked at this stage:
-        this.game.vname = p.vname;
-        this.game.mycolor = V.ParseFen(p.fen).turn; //diagram orientation
-        this.game.fen = p.fen;
-        this.$set(this.game, "fenStart", p.fen);
-        this.copyProblem(p, this.curproblem);
-        this.showOne = true;
-      });
+    showProblem: function(p_id) {
+      const processWhenWeHaveProb = () => {
+        this.loadVariant(p.vid, () => {
+          this.onlyMine = (p.uid == this.st.user.id);
+          // The FEN is already checked at this stage:
+          this.game.vname = p.vname;
+          this.game.mycolor = V.ParseFen(p.fen).turn; //diagram orientation
+          this.game.fenStart = p.fen;
+          this.game.fen = p.fen;
+          this.showOne = true;
+          // $nextTick to be sure $refs["basegame"] exists
+          this.$nextTick(() => {
+            this.$refs["basegame"].re_setVariables(this.game); });
+          this.curproblem.showSolution = false; //in case of
+          this.copyProblem(p, this.curproblem);
+        });
+      };
+      let p = undefined;
+      if (typeof p_id == "object") p = p_id;
+      else {
+        const problems = this.problems["others"].concat(this.problems["mine"]);
+        p = problems.find(prob => prob.id == p_id);
+      }
+      if (!p) {
+        // Bad luck: problem not in list. Get from server
+        ajax(
+          "/problems",
+          "GET",
+          {
+            data: { id: p_id },
+            success: (res) => {
+              this.decorate([res.problem], () => {
+                p = res.problem;
+                const mode = (p.uid == this.st.user.id ? "mine" : "others");
+                this.problems[mode].push(p);
+                processWhenWeHaveProb();
+              });
+            }
+          }
+        );
+      } else processWhenWeHaveProb();
     },
-    gotoPrevNext: function(e, prob, dir) {
-      const startIdx = this.problems.findIndex(p => p.id == prob.id);
-      let nextIdx = startIdx + dir;
-      while (
-        nextIdx >= 0 &&
-        nextIdx < this.problems.length &&
-        ((this.onlyMines && this.problems[nextIdx].uid != this.st.user.id) ||
-          (!this.onlyMines && this.problems[nextIdx].uid == this.st.user.id))
-      )
-        nextIdx += dir;
-      if (nextIdx >= 0 && nextIdx < this.problems.length)
-        this.setHrefPid(this.problems[nextIdx]);
-      else
-        alert(this.st.tr["No more problems"]);
+    gotoPrevNext: function(prob, dir) {
+      const mode = (this.onlyMine ? "mine" : "others");
+      const problems = this.problems[mode];
+      const startIdx = problems.findIndex(p => p.id == prob.id);
+      const nextIdx = startIdx + dir;
+      if (nextIdx >= 0 && nextIdx < problems.length)
+        this.setHrefPid(problems[nextIdx]);
+      else if (this.hasMore[mode]) {
+        this.loadMore(
+          mode,
+          (nbProbs) => {
+            if (nbProbs > 0) this.gotoPrevNext(prob, dir);
+            else alert(this.st.tr["No more problems"]);
+          }
+        );
+      }
+      else alert(this.st.tr["No more problems"]);
     },
     prepareNewProblem: function() {
       this.resetCurProb();
+      this.adjustHeight("instructions");
+      this.adjustHeight("solution");
       window.doClick("modalNewprob");
     },
     sendProblem: function() {
@@ -322,53 +420,129 @@ export default {
       ajax(
         "/problems",
         edit ? "PUT" : "POST",
-        { prob: this.curproblem },
-        ret => {
-          if (edit) {
-            let editedP = this.problems.find(p => p.id == this.curproblem.id);
-            this.copyProblem(this.curproblem, editedP);
-            this.showProblem(editedP);
-          }
-          else {
-            let newProblem = Object.assign({}, this.curproblem);
-            newProblem.id = ret.id;
-            newProblem.uid = this.st.user.id;
-            newProblem.uname = this.st.user.name;
-            this.problems = [newProblem].concat(this.problems);
+        {
+          data: { prob: this.curproblem },
+          success: (ret) => {
+            if (edit) {
+              let editedP = this.problems["mine"]
+                .find(p => p.id == this.curproblem.id);
+              if (!editedP)
+                // I'm an admin and edit another user' problem
+                editedP = this.problems["others"]
+                  .find(p => p.id == this.curproblem.id);
+              this.copyProblem(this.curproblem, editedP);
+              this.showProblem(editedP);
+            }
+            else {
+              let newProblem = Object.assign({}, this.curproblem);
+              newProblem.id = ret.id;
+              newProblem.uid = this.st.user.id;
+              newProblem.uname = this.st.user.name;
+              this.problems["mine"] =
+                [newProblem].concat(this.problems["mine"]);
+            }
+            document.getElementById("modalNewprob").checked = false;
+            this.infoMsg = "";
           }
-          document.getElementById("modalNewprob").checked = false;
-          this.infoMsg = "";
         }
       );
     },
+    canIedit: function(puid) {
+      return params.devs.concat([puid]).includes(this.st.user.id);
+    },
     editProblem: function(prob) {
       // prob.diag might correspond to some other problem or be empty:
       this.setDiagram(prob); //V is loaded at this stage
       this.copyProblem(prob, this.curproblem);
+      this.adjustHeight("instructions");
+      this.adjustHeight("solution");
       window.doClick("modalNewprob");
     },
     deleteProblem: function(prob) {
       if (confirm(this.st.tr["Are you sure?"])) {
-        ajax("/problems", "DELETE", { id: prob.id }, () => {
-          ArrayFun.remove(this.problems, p => p.id == prob.id);
-          this.backToList();
-        });
+        ajax(
+          "/problems",
+          "DELETE",
+          {
+            data: { id: prob.id },
+            success: () => {
+              const mode = prob.uid == (this.st.user.id ? "mine" : "others");
+              ArrayFun.remove(this.problems[mode], p => p.id == prob.id);
+              this.backToList();
+            }
+          }
+        );
       }
+    },
+    loadMore: function(mode, cb) {
+      ajax(
+        "/problems",
+        "GET",
+        {
+          data: {
+            uid: this.st.user.id,
+            mode: mode,
+            cursor: this.cursor[mode]
+          },
+          success: (res) => {
+            const L = res.problems.length;
+            if (L > 0) {
+              this.cursor[mode] = res.problems[L - 1].added;
+              // Remove potential duplicates:
+              const pids = this.problems[mode].map(p => p.id);
+              ArrayFun.remove(res.problems, p => pids.includes(p.id), "all");
+              this.decorate(res.problems);
+              this.problems[mode] =
+                this.problems[mode].concat(res.problems)
+                // TODO: problems are already sorted, would just need to insert
+                // the current individual problem in list; more generally
+                // there is probably only one misclassified problem.
+                // (Unless the user navigated several times by URL to show a
+                // single problem...)
+                .sort((p1, p2) => p2.added - p1.added);
+            } else this.hasMore[mode] = false;
+            if (!!cb) cb(L);
+          }
+        }
+      );
     }
   }
 };
 </script>
 
+<style lang="sass">
+@import "@/styles/_board_squares_img.sass"
+@import "@/styles/_rules.sass"
+.br
+  display: block
+  margin: 10px 0
+</style>
+
 <style lang="sass" scoped>
 [type="checkbox"].modal+div .card
   max-width: 767px
   max-height: 100%
 
+#rulesDiv > .card
+  padding: 5px 0
+  max-width: 50%
+  max-height: 100%
+  @media screen and (max-width: 1500px)
+    max-width: 67%
+  @media screen and (max-width: 1024px)
+    max-width: 85%
+  @media screen and (max-width: 767px)
+    max-width: 100%
+
 #inputFen
   width: 100%
 
 textarea
   width: 100%
+  &.instructions-edit
+    min-height: 70px
+  &.solution-edit
+    min-height: 100px
 
 #diagram
   margin: 0 auto
@@ -377,6 +551,10 @@ textarea
 table#tProblems
   max-height: 100%
 
+button#loadMoreBtn
+  display: block
+  margin: 0 auto
+
 #controls
   margin: 0
   width: 100%
@@ -389,6 +567,9 @@ p.oneInstructions
   padding: 2px 5px
   background-color: lightgreen
 
+#myProblems
+  display: inline-block
+
 #topPage
   span.vname
     font-weight: bold
@@ -404,4 +585,12 @@ p.oneInstructions
 @media screen and (max-width: 767px)
   #topPage
     text-align: center
+
+a#variantNameInProblems
+  color: var(--card-fore-color)
+  text-align: center
+  font-weight: bold
+  font-size: calc(1rem * var(--heading-ratio))
+  line-height: 1.2
+  margin: calc(1.5 * var(--universal-margin))
 </style>