浏览代码

Add original curation front-end

Daoud Clarke 1 年之前
父节点
当前提交
4d823497a6

+ 94 - 0
front-end/assets/css/global.css

@@ -117,6 +117,10 @@ mwmbl-results, footer {
   padding: 10px;
   padding: 10px;
 }
 }
 
 
+.result {
+  min-height: 120px;
+}
+
 .result a {
 .result a {
   display: block;
   display: block;
   text-decoration: none;
   text-decoration: none;
@@ -229,4 +233,94 @@ a {
   font-weight: var(--bold-font-weight);
   font-weight: var(--bold-font-weight);
   color: var(--primary-color);
   color: var(--primary-color);
   text-decoration: underline;
   text-decoration: underline;
+}
+
+.result-container {
+  display: flex;
+}
+
+.curation-buttons {
+  padding: 20px;
+}
+
+.curation-button {
+  opacity: 0;
+  color: inherit;
+  border: none;
+  padding: 0;
+  font: inherit;
+  outline: inherit;
+  cursor: pointer;
+
+  background: darkgrey;
+  box-shadow: 3px 3px 3px lightgrey;
+  width: 20px;
+  height: 20px;
+  border-radius: 50%;
+  display: flex; /* or inline-flex */
+  align-items: center;
+  justify-content: center;
+  margin: 10px 0 10px 0;
+}
+
+.result:hover .curation-button {
+  opacity: 70%;
+  transition:
+    opacity 200ms ease-in-out;
+}
+
+.result:hover .curation-button:hover {
+  opacity: 100%;
+}
+
+.curate-delete {
+  margin-top: 0;
+}
+
+.validated {
+  background: lightgreen;
+  opacity: 100%;
+}
+
+.curate-add {
+  margin-bottom: 0;
+}
+
+
+.modal {
+  /*display: none; !* Hidden by default *!*/
+  position: fixed; /* Stay in place */
+  z-index: 100; /* Sit on top */
+  left: 0;
+  top: 0;
+  width: 100%; /* Full width */
+  height: 100%; /* Full height */
+  overflow: auto; /* Enable scroll if needed */
+  background-color: rgb(0,0,0); /* Fallback color */
+  background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
+}
+
+/* Modal Content/Box */
+.modal-content {
+  background-color: #fefefe;
+  margin: 15% auto; /* 15% from the top and centered */
+  padding: 20px;
+  border: 1px solid #888;
+  max-width: 800px;
+  width: 80%; /* Could be more or less, depending on screen size */
+}
+
+/* The Close Button */
+.close {
+  color: #aaa;
+  float: right;
+  font-size: 28px;
+  font-weight: bold;
+}
+
+.close:hover,
+.close:focus {
+  color: black;
+  text-decoration: none;
+  cursor: pointer;
 }
 }

文件差异内容过多而无法显示
+ 5 - 5
front-end/assets/opensearch.xml


+ 1 - 0
front-end/config.js

@@ -9,6 +9,7 @@
 export default {
 export default {
   componentPrefix: 'mwmbl',
   componentPrefix: 'mwmbl',
   publicApiURL: 'https://api.mwmbl.org/',
   publicApiURL: 'https://api.mwmbl.org/',
+  // publicApiURL: 'http://localhost:5000/',
   searchQueryParam: 'q',
   searchQueryParam: 'q',
   footerLinks: [
   footerLinks: [
     {
     {

+ 20 - 62
front-end/package-lock.json

@@ -5,9 +5,6 @@
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "front-end",
       "name": "front-end",
-      "dependencies": {
-        "chart.js": "^4.4.0"
-      },
       "devDependencies": {
       "devDependencies": {
         "@vitejs/plugin-legacy": "^2.3.1",
         "@vitejs/plugin-legacy": "^2.3.1",
         "terser": "^5.16.1",
         "terser": "^5.16.1",
@@ -113,11 +110,6 @@
         "@jridgewell/sourcemap-codec": "1.4.14"
         "@jridgewell/sourcemap-codec": "1.4.14"
       }
       }
     },
     },
-    "node_modules/@kurkle/color": {
-      "version": "0.3.2",
-      "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
-      "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
-    },
     "node_modules/@vitejs/plugin-legacy": {
     "node_modules/@vitejs/plugin-legacy": {
       "version": "2.3.1",
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/@vitejs/plugin-legacy/-/plugin-legacy-2.3.1.tgz",
       "resolved": "https://registry.npmjs.org/@vitejs/plugin-legacy/-/plugin-legacy-2.3.1.tgz",
@@ -156,17 +148,6 @@
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
       "dev": true
       "dev": true
     },
     },
-    "node_modules/chart.js": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.0.tgz",
-      "integrity": "sha512-vQEj6d+z0dcsKLlQvbKIMYFHd3t8W/7L2vfJIbYcfyPcRx92CsHqECpueN8qVGNlKyDcr5wBrYAYKnfu/9Q1hQ==",
-      "dependencies": {
-        "@kurkle/color": "^0.3.0"
-      },
-      "engines": {
-        "pnpm": ">=7"
-      }
-    },
     "node_modules/commander": {
     "node_modules/commander": {
       "version": "2.20.3",
       "version": "2.20.3",
       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -598,16 +579,10 @@
       }
       }
     },
     },
     "node_modules/nanoid": {
     "node_modules/nanoid": {
-      "version": "3.3.6",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
-      "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
+      "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
       "dev": true,
       "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
-        }
-      ],
       "bin": {
       "bin": {
         "nanoid": "bin/nanoid.cjs"
         "nanoid": "bin/nanoid.cjs"
       },
       },
@@ -628,9 +603,9 @@
       "dev": true
       "dev": true
     },
     },
     "node_modules/postcss": {
     "node_modules/postcss": {
-      "version": "8.4.31",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
-      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+      "version": "8.4.19",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz",
+      "integrity": "sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==",
       "dev": true,
       "dev": true,
       "funding": [
       "funding": [
         {
         {
@@ -640,14 +615,10 @@
         {
         {
           "type": "tidelift",
           "type": "tidelift",
           "url": "https://tidelift.com/funding/github/npm/postcss"
           "url": "https://tidelift.com/funding/github/npm/postcss"
-        },
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/ai"
         }
         }
       ],
       ],
       "dependencies": {
       "dependencies": {
-        "nanoid": "^3.3.6",
+        "nanoid": "^3.3.4",
         "picocolors": "^1.0.0",
         "picocolors": "^1.0.0",
         "source-map-js": "^1.0.2"
         "source-map-js": "^1.0.2"
       },
       },
@@ -765,9 +736,9 @@
       }
       }
     },
     },
     "node_modules/vite": {
     "node_modules/vite": {
-      "version": "3.2.7",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz",
-      "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==",
+      "version": "3.2.5",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.5.tgz",
+      "integrity": "sha512-4mVEpXpSOgrssFZAOmGIr85wPHKvaDAcXqxVxVRZhljkJOMZi1ibLibzjLHzJvcok8BMguLc7g1W6W/GqZbLdQ==",
       "dev": true,
       "dev": true,
       "dependencies": {
       "dependencies": {
         "esbuild": "^0.15.9",
         "esbuild": "^0.15.9",
@@ -884,11 +855,6 @@
         "@jridgewell/sourcemap-codec": "1.4.14"
         "@jridgewell/sourcemap-codec": "1.4.14"
       }
       }
     },
     },
-    "@kurkle/color": {
-      "version": "0.3.2",
-      "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
-      "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
-    },
     "@vitejs/plugin-legacy": {
     "@vitejs/plugin-legacy": {
       "version": "2.3.1",
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/@vitejs/plugin-legacy/-/plugin-legacy-2.3.1.tgz",
       "resolved": "https://registry.npmjs.org/@vitejs/plugin-legacy/-/plugin-legacy-2.3.1.tgz",
@@ -914,14 +880,6 @@
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
       "dev": true
       "dev": true
     },
     },
-    "chart.js": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.0.tgz",
-      "integrity": "sha512-vQEj6d+z0dcsKLlQvbKIMYFHd3t8W/7L2vfJIbYcfyPcRx92CsHqECpueN8qVGNlKyDcr5wBrYAYKnfu/9Q1hQ==",
-      "requires": {
-        "@kurkle/color": "^0.3.0"
-      }
-    },
     "commander": {
     "commander": {
       "version": "2.20.3",
       "version": "2.20.3",
       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -1145,9 +1103,9 @@
       }
       }
     },
     },
     "nanoid": {
     "nanoid": {
-      "version": "3.3.6",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
-      "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+      "version": "3.3.4",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
+      "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
       "dev": true
       "dev": true
     },
     },
     "path-parse": {
     "path-parse": {
@@ -1163,12 +1121,12 @@
       "dev": true
       "dev": true
     },
     },
     "postcss": {
     "postcss": {
-      "version": "8.4.31",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
-      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+      "version": "8.4.19",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz",
+      "integrity": "sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==",
       "dev": true,
       "dev": true,
       "requires": {
       "requires": {
-        "nanoid": "^3.3.6",
+        "nanoid": "^3.3.4",
         "picocolors": "^1.0.0",
         "picocolors": "^1.0.0",
         "source-map-js": "^1.0.2"
         "source-map-js": "^1.0.2"
       }
       }
@@ -1252,9 +1210,9 @@
       }
       }
     },
     },
     "vite": {
     "vite": {
-      "version": "3.2.7",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz",
-      "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==",
+      "version": "3.2.5",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.5.tgz",
+      "integrity": "sha512-4mVEpXpSOgrssFZAOmGIr85wPHKvaDAcXqxVxVRZhljkJOMZi1ibLibzjLHzJvcok8BMguLc7g1W6W/GqZbLdQ==",
       "dev": true,
       "dev": true,
       "requires": {
       "requires": {
         "esbuild": "^0.15.9",
         "esbuild": "^0.15.9",

+ 0 - 3
front-end/package.json

@@ -11,8 +11,5 @@
     "@vitejs/plugin-legacy": "^2.3.1",
     "@vitejs/plugin-legacy": "^2.3.1",
     "terser": "^5.16.1",
     "terser": "^5.16.1",
     "vite": "^3.2.3"
     "vite": "^3.2.3"
-  },
-  "dependencies": {
-    "chart.js": "^4.4.0"
   }
   }
 }
 }

+ 10 - 4
front-end/src/components/app.js

@@ -1,16 +1,22 @@
 import define from '../utils/define.js';
 import define from '../utils/define.js';
+import addResult from "./molecules/add-result.js";
+import save from "./organisms/save.js";
 
 
 const template = () => /*html*/`
 const template = () => /*html*/`
   <header class="search-menu">
   <header class="search-menu">
+    <ul>
+      <li is="${save}"></li>
+    </ul>
     <div class="branding">
     <div class="branding">
-      <img class="brand-icon" src="/static/images/logo.svg" width="40" height="40" alt="mwmbl logo">
+      <img class="brand-icon" src="/images/logo.svg" width="40" height="40" alt="mwmbl logo">
       <span class="brand-title">MWMBL</span>
       <span class="brand-title">MWMBL</span>
     </div>
     </div>
     <mwmbl-search-bar></mwmbl-search-bar>
     <mwmbl-search-bar></mwmbl-search-bar>
   </header>
   </header>
-    <main>
-      <mwmbl-results></mwmbl-results>
-    </main>
+  <main>
+    <mwmbl-results></mwmbl-results>
+  </main>
+  <div is="${addResult}"></div>
   <footer is="mwmbl-footer"></footer>
   <footer is="mwmbl-footer"></footer>
 `;
 `;
 
 

+ 69 - 0
front-end/src/components/login.js

@@ -0,0 +1,69 @@
+import define from '../utils/define.js';
+import config from "../../config.js";
+
+const template = () => /*html*/`
+  <form>
+    <h5>Login</h5>
+    <div>
+      <label for="login-email-or-username">Email or Username</label>
+      <div>
+        <input class="form-control" type="text" id="login-email-or-username" autocomplete="email" required="" minlength="3">
+      </div>
+    </div>
+    <div>
+      <label for="login-password">Password</label>
+      <div>
+        <input type="password" id="login-password" autocomplete="current-password" required="" maxlength="60">
+        <button type="button" disabled="" title="You will not be able to reset your password without an email.">forgot password</button>
+      </div>
+    </div>
+    <div>
+      <button class="btn btn-secondary" type="submit">Login</button>
+    </div>
+  </form>
+`;
+
+export default define('login', class extends HTMLElement {
+  constructor() {
+    super();
+    this.loginForm = null;
+    this.emailOrUsernameInput = null;
+    this.passwordInput = null;
+    this.__setup();
+    this.__events();
+  }
+
+  __setup() {
+    this.innerHTML = template();
+    this.loginForm = this.querySelector('form');
+    this.emailOrUsernameInput = this.querySelector('#login-email-or-username');
+    this.passwordInput = this.querySelector('#login-password');
+  }
+
+  __events() {
+    this.loginForm.addEventListener('submit', (e) => {
+      e.preventDefault();
+      this.__handleLogin(e);
+    });
+  }
+
+  __handleLogin = async () => {
+    const response = await fetch(`${config.publicApiURL}user/login`, {
+        method: 'POST',
+        cache: 'no-cache',
+        headers: {'Content-Type': 'application/json'},
+        body: JSON.stringify({
+          "username_or_email": this.emailOrUsernameInput.value,
+          "password": this.passwordInput.value,
+        })
+      });
+    if (response.status === 200) {
+      const loginData = await response.json();
+      console.log("Login data", loginData);
+      document.cookie = `jwt=${loginData["jwt"]}; SameSite=Strict`;
+      console.log("Login success");
+    } else {
+      console.log("Login error", response);
+    }
+  }
+});

+ 24 - 0
front-end/src/components/molecules/add-button.js

@@ -0,0 +1,24 @@
+import define from "../../utils/define.js";
+import {globalBus} from "../../utils/events.js";
+import addResult from "./add-result.js";
+import emptyResult from "./empty-result.js";
+
+
+export default define('add-button', class extends HTMLButtonElement {
+  constructor() {
+    super();
+    this.__setup();
+  }
+
+  __setup() {
+    this.__events();
+  }
+
+  __events() {
+    this.addEventListener('click', (e) => {
+      console.log("Add button");
+      document.querySelector('.modal').style.display = 'block';
+      document.querySelector('.modal input').focus();
+    })
+  }
+}, { extends: 'button' });

+ 69 - 0
front-end/src/components/molecules/add-result.js

@@ -0,0 +1,69 @@
+import define from '../../utils/define.js';
+import config from "../../../config.js";
+import {globalBus} from "../../utils/events.js";
+
+
+const FETCH_URL = `${config['publicApiURL']}crawler/fetch?`
+
+
+const template = () => /*html*/`
+    <form class="modal-content">
+      <span class="close">&times;</span>
+      <input class="add-result" placeholder="Enter a URL...">
+      <button>Save</button>
+    </form>
+`;
+
+export default define('add-result', class extends HTMLDivElement {
+  constructor() {
+    super();
+    this.classList.add('modal');
+    this.__setup();
+  }
+
+  __setup() {
+    this.innerHTML = template();
+    this.__events();
+    this.style.display = 'none';
+  }
+
+  __events() {
+    this.querySelector('.close').addEventListener('click', e => {
+      if (e.target === this) {
+        this.style.display = 'none';
+      }
+    });
+
+    this.addEventListener('click', e => {
+      this.style.display = 'none';
+    });
+
+    this.querySelector('form').addEventListener('click', e => {
+      // Clicking on the form shouldn't close it
+      e.stopPropagation();
+    });
+
+    this.addEventListener('submit', this.__urlSubmitted.bind(this));
+  }
+
+  async __urlSubmitted(e) {
+    e.preventDefault();
+    const value = this.querySelector('input').value;
+    console.log("Input value", value);
+
+    const query = document.querySelector('.search-bar input').value;
+
+    const url = `${FETCH_URL}url=${encodeURIComponent(value)}&query=${encodeURIComponent(query)}`;
+    const response = await fetch(url);
+    if (response.status === 200) {
+      const data = await response.json();
+      console.log("Data", data);
+
+      const addResultEvent = new CustomEvent('curate-add-result', {detail: data});
+      globalBus.dispatch(addResultEvent);
+    } else {
+      console.log("Bad response", response);
+      // TODO
+    }
+  }
+}, { extends: 'div' });

+ 35 - 0
front-end/src/components/molecules/delete-button.js

@@ -0,0 +1,35 @@
+import define from "../../utils/define.js";
+import {globalBus} from "../../utils/events.js";
+
+
+export default define('delete-button', class extends HTMLButtonElement {
+  constructor() {
+    super();
+    this.__setup();
+  }
+
+  __setup() {
+    this.__events();
+  }
+
+  __events() {
+    this.addEventListener('click', (e) => {
+      console.log("Delete button");
+
+      const result = this.closest('.result');
+      const parent = result.parentNode;
+
+      const index = Array.prototype.indexOf.call(parent.children, result);
+      console.log("Delete index", index);
+
+      const beginCuratingEvent = new CustomEvent('curate-delete-result', {
+        detail: {
+          data: {
+            delete_index: index
+          }
+        }
+      });
+      globalBus.dispatch(beginCuratingEvent);
+    })
+  }
+}, { extends: 'button' });

+ 17 - 5
front-end/src/components/molecules/result.js

@@ -1,13 +1,25 @@
 import define from '../../utils/define.js';
 import define from '../../utils/define.js';
 import escapeString from '../../utils/escapeString.js';
 import escapeString from '../../utils/escapeString.js';
 import { globalBus } from '../../utils/events.js';
 import { globalBus } from '../../utils/events.js';
+import deleteButton from "./delete-button.js";
+import validateButton from "./validate-button.js";
+import addButton from "./add-button.js";
 
 
 const template = ({ data }) => /*html*/`
 const template = ({ data }) => /*html*/`
-  <a href='${data.url}'>
-    <p class='link'>${data.url}</p>
-    <p class='title'>${data.title}</p>
-    <p class='extract'>${data.extract}</p>
-  </a>
+  <div class="result-container">
+    <div class="curation-buttons">
+      <button class="curation-button curate-delete" is="${deleteButton}">✕</button>
+      <button class="curation-button curate-approve" is="${validateButton}">✓</button>
+      <button class="curation-button curate-add" is="${addButton}">+</button>
+    </div>
+    <div class="result-link">
+      <a href='${data.url}'>
+        <p class='link'>${data.url}</p>
+        <p class='title'>${data.title}</p>
+        <p class='extract'>${data.extract}</p>
+      </a>
+    </div>
+  </div>
 `;
 `;
 
 
 export default define('result', class extends HTMLLIElement {
 export default define('result', class extends HTMLLIElement {

+ 53 - 0
front-end/src/components/molecules/validate-button.js

@@ -0,0 +1,53 @@
+import define from "../../utils/define.js";
+import {globalBus} from "../../utils/events.js";
+
+
+const VALIDATED_CLASS = "validated";
+
+export default define('validate-button', class extends HTMLButtonElement {
+  constructor() {
+    super();
+    this.__setup();
+  }
+
+  __setup() {
+    this.__events();
+  }
+
+  __events() {
+    this.addEventListener('click', (e) => {
+      console.log("Validate button");
+
+      const result = this.closest('.result');
+      const parent = result.parentNode;
+
+      const index = Array.prototype.indexOf.call(parent.children, result);
+      console.log("Validate index", index);
+
+      const curationValidateEvent = new CustomEvent('curate-validate-result', {
+        detail: {
+          data: {
+            validate_index: index
+          }
+        }
+      });
+      globalBus.dispatch(curationValidateEvent);
+    })
+  }
+
+  isValidated() {
+    return this.classList.contains(VALIDATED_CLASS);
+  }
+
+  validate() {
+    this.classList.add(VALIDATED_CLASS);
+  }
+
+  unvalidate() {
+    this.classList.remove(VALIDATED_CLASS);
+  }
+
+  toggleValidate() {
+    this.classList.toggle(VALIDATED_CLASS);
+  }
+}, { extends: 'button' });

+ 5 - 5
front-end/src/components/organisms/footer.js

@@ -4,14 +4,14 @@ import config from '../../../config.js';
 const template = ({ data }) => /*html*/`
 const template = ({ data }) => /*html*/`
   <p class="footer-text">Find more on</p>
   <p class="footer-text">Find more on</p>
   <ul class="footer-list">
   <ul class="footer-list">
-    ${data.links.map(link => /*html*/`
+    ${ data.links.map(link => /*html*/`
       <li class="footer-item">
       <li class="footer-item">
-        <a href="${link.href}" class="footer-link" target="_blank">
+        <a href="${link.href}" class="footer-link" target="__blank">
           <i class="${link.icon}"></i>
           <i class="${link.icon}"></i>
           <span>${link.name}</span>
           <span>${link.name}</span>
         </a>
         </a>
       </li>
       </li>
-    `).join('')}
+    `).join('') }
   </ul>
   </ul>
 `;
 `;
 
 
@@ -22,7 +22,7 @@ export default define('footer', class extends HTMLElement {
   }
   }
 
 
   __setup() {
   __setup() {
-    this.innerHTML = template({
+    this.innerHTML = template({ 
       data: {
       data: {
         links: config.footerLinks
         links: config.footerLinks
       }
       }
@@ -31,6 +31,6 @@ export default define('footer', class extends HTMLElement {
   }
   }
 
 
   __events() {
   __events() {
-
+    
   }
   }
 }, { extends: 'footer' });
 }, { extends: 'footer' });

+ 162 - 4
front-end/src/components/organisms/results.js

@@ -1,5 +1,5 @@
 import define from '../../utils/define.js';
 import define from '../../utils/define.js';
-import { globalBus } from '../../utils/events.js';
+import {globalBus} from '../../utils/events.js';
 
 
 // Components
 // Components
 import result from '../molecules/result.js';
 import result from '../molecules/result.js';
@@ -17,6 +17,8 @@ export default define('results', class extends HTMLElement {
   constructor() {
   constructor() {
     super();
     super();
     this.results = null;
     this.results = null;
+    this.oldIndex = null;
+    this.curating = false;
     this.__setup();
     this.__setup();
   }
   }
 
 
@@ -31,7 +33,7 @@ export default define('results', class extends HTMLElement {
       this.results.innerHTML = '';
       this.results.innerHTML = '';
       let resultsHTML = '';
       let resultsHTML = '';
       if (!e.detail.error) {
       if (!e.detail.error) {
-        // If there is no details the input is empty 
+        // If there is no details the input is empty
         if (!e.detail.results) {
         if (!e.detail.results) {
           resultsHTML = /*html*/`
           resultsHTML = /*html*/`
             <li is='${home}'></li>
             <li is='${home}'></li>
@@ -42,7 +44,7 @@ export default define('results', class extends HTMLElement {
           for(const resultData of e.detail.results) {
           for(const resultData of e.detail.results) {
             resultsHTML += /*html*/`
             resultsHTML += /*html*/`
               <li
               <li
-                is='${result}' 
+                is='${result}'
                 data-url='${escapeString(resultData.url)}'
                 data-url='${escapeString(resultData.url)}'
                 data-title='${escapeString(JSON.stringify(resultData.title))}'
                 data-title='${escapeString(JSON.stringify(resultData.title))}'
                 data-extract='${escapeString(JSON.stringify(resultData.extract))}'
                 data-extract='${escapeString(JSON.stringify(resultData.extract))}'
@@ -65,11 +67,167 @@ export default define('results', class extends HTMLElement {
       }
       }
       // Bind HTML to the DOM
       // Bind HTML to the DOM
       this.results.innerHTML = resultsHTML;
       this.results.innerHTML = resultsHTML;
+
+      // Allow the user to re-order search results
+      $(".results").sortable({
+        "activate": this.__sortableActivate.bind(this),
+        "deactivate": this.__sortableDeactivate.bind(this),
+      });
+
+      this.curating = false;
     });
     });
 
 
     // Focus first element when coming from the search bar
     // Focus first element when coming from the search bar
     globalBus.on('focus-result', () => {
     globalBus.on('focus-result', () => {
       this.results.firstElementChild.firstElementChild.focus();
       this.results.firstElementChild.firstElementChild.focus();
-    })
+    });
+
+    globalBus.on('curate-delete-result',  (e) => {
+      console.log("Curate delete result event", e);
+      this.__beginCurating.bind(this)();
+
+      const children = this.results.getElementsByClassName('result');
+      let deleteIndex = e.detail.data.delete_index;
+      const child = children[deleteIndex];
+      this.results.removeChild(child);
+      const newResults = this.__getResults();
+
+      const curationSaveEvent = new CustomEvent('save-curation', {
+        detail: {
+          type: 'delete',
+          data: {
+            url: document.location.href,
+            results: newResults,
+            curation: {
+              delete_index: deleteIndex
+            }
+          }
+        }
+      });
+      globalBus.dispatch(curationSaveEvent);
+    });
+
+    globalBus.on('curate-validate-result',  (e) => {
+      console.log("Curate validate result event", e);
+      this.__beginCurating.bind(this)();
+
+      const children = this.results.getElementsByClassName('result');
+      const validateChild = children[e.detail.data.validate_index];
+      validateChild.querySelector('.curate-approve').toggleValidate();
+
+      const newResults = this.__getResults();
+
+      const curationStartEvent = new CustomEvent('save-curation', {
+        detail: {
+          type: 'validate',
+          data: {
+            url: document.location.href,
+            results: newResults,
+            curation: e.detail.data
+          }
+        }
+      });
+      globalBus.dispatch(curationStartEvent);
+    });
+
+    globalBus.on('begin-curating-results',  (e) => {
+      // We might not be online, or logged in, so save the curation in local storage in case:
+      console.log("Begin curation event", e);
+      this.__beginCurating.bind(this)();
+    });
+
+    globalBus.on('curate-add-result', (e) => {
+      console.log("Add result", e);
+      this.__beginCurating();
+      const resultData = e.detail;
+      const resultHTML = /*html*/`
+        <li
+          is='${result}'
+          data-url='${escapeString(resultData.url)}'
+          data-title='${escapeString(JSON.stringify(resultData.title))}'
+          data-extract='${escapeString(JSON.stringify(resultData.extract))}'
+        ></li>
+      `;
+      this.results.insertAdjacentHTML('afterbegin', resultHTML);
+
+      const newResults = this.__getResults();
+
+      const curationSaveEvent = new CustomEvent('save-curation', {
+      detail: {
+        type: 'add',
+        data: {
+          url: document.location.href,
+          results: newResults,
+          curation: {
+            insert_index: 0,
+            url: e.detail.url
+          }
+        }
+      }
+    });
+    globalBus.dispatch(curationSaveEvent);
+
+    });
+  }
+
+  __sortableActivate(event, ui) {
+    console.log("Sortable activate", ui);
+    this.__beginCurating();
+    this.oldIndex = ui.item.index();
+  }
+
+  __beginCurating() {
+    if (!this.curating) {
+      const results = this.__getResults();
+      const curationStartEvent = new CustomEvent('save-curation', {
+        detail: {
+          type: 'begin',
+          data: {
+            url: document.location.href,
+            results: results
+          }
+        }
+      });
+      globalBus.dispatch(curationStartEvent);
+      this.curating = true;
+    }
+  }
+
+  __getResults() {
+    const resultsElements = document.querySelectorAll('.results .result:not(.ui-sortable-placeholder)');
+    const results = [];
+    for (let resultElement of resultsElements) {
+      const result = {
+        url: resultElement.querySelector('a').href,
+        title: resultElement.querySelector('.title').innerText,
+        extract: resultElement.querySelector('.extract').innerText,
+        curated: resultElement.querySelector('.curate-approve').isValidated()
+      }
+      results.push(result);
+    }
+    console.log("Results", results);
+    return results;
+  }
+
+  __sortableDeactivate(event, ui) {
+    const newIndex = ui.item.index();
+    console.log('Sortable deactivate', ui, this.oldIndex, newIndex);
+
+    const newResults = this.__getResults();
+
+    const curationMoveEvent = new CustomEvent('save-curation', {
+      detail: {
+        type: 'move',
+        data: {
+          url: document.location.href,
+          results: newResults,
+          curation: {
+            old_index: this.oldIndex,
+            new_index: newIndex,
+          }
+        }
+      }
+    });
+    globalBus.dispatch(curationMoveEvent);
   }
   }
 });
 });

+ 122 - 0
front-end/src/components/organisms/save.js

@@ -0,0 +1,122 @@
+import define from '../../utils/define.js';
+import {globalBus} from "../../utils/events.js";
+import config from "../../../config.js";
+
+
+const CURATION_KEY_PREFIX = "curation-";
+const CURATION_URL = config.publicApiURL + "user/curation/";
+
+
+const template = () => /*html*/`
+  <span>🖫</span>
+`;
+
+
+export default define('save', class extends HTMLLIElement {
+  constructor() {
+    super();
+    this.currentCurationId = null;
+    this.classList.add('save');
+    this.sendId = 0;
+    this.sending = false;
+    this.__setup();
+  }
+
+  __setup() {
+    this.innerHTML = template();
+    this.__events();
+    // TODO: figure out when to call __sendToApi()
+    // setInterval(this.__sendToApi.bind(this), 1000);
+  }
+
+  __events() {
+    globalBus.on('save-curation', (e) => {
+      // We might not be online, or logged in, so save the curation in local storage in case:
+      console.log("Curation event", e);
+      this.__setCuration(e.detail);
+      this.__sendToApi();
+    });
+  }
+
+  __setCuration(curation) {
+    this.sendId += 1;
+    const key = CURATION_KEY_PREFIX + this.sendId;
+    localStorage.setItem(key, JSON.stringify(curation));
+  }
+
+  __getOldestCurationKey() {
+    let oldestId = Number.MAX_SAFE_INTEGER;
+    let oldestKey = null;
+    for (let i=0; i<localStorage.length; ++i) {
+      const key = localStorage.key(i);
+      if (key.startsWith(CURATION_KEY_PREFIX)) {
+        const timestamp = parseInt(key.substring(CURATION_KEY_PREFIX.length));
+        if (timestamp < oldestId) {
+          oldestKey = key;
+          oldestId = timestamp;
+        }
+      }
+    }
+    return oldestKey;
+  }
+
+  async __sendToApi() {
+    if (this.sending) {
+      return;
+    }
+    this.sending = true;
+    const auth = document.cookie
+      .split('; ')
+      .find((row) => row.startsWith('jwt='))
+      ?.split('=')[1];
+
+    if (!auth) {
+      console.log("No auth");
+      return;
+    }
+
+    if (localStorage.length > 0) {
+      const key = this.__getOldestCurationKey();
+      const value = JSON.parse(localStorage.getItem(key));
+      console.log("Value", value);
+      const url = CURATION_URL + value['type'];
+
+      let data = value['data'];
+      if (value.type !== 'begin') {
+        if (this.currentCurationId === null) {
+          throw ReferenceError("No current curation found");
+        }
+        data['curation_id'] = this.currentCurationId;
+      }
+      data['auth'] = auth;
+
+      console.log("Data", data);
+      const response = await fetch(url, {
+          method: 'POST',
+          cache: 'no-cache',
+          headers: {'Content-Type': 'application/json'},
+          body: JSON.stringify(data),
+        });
+
+      console.log("Save curation API response", response);
+
+      if (response.status === 200) {
+        localStorage.removeItem(key);
+      } else {
+        console.log("Bad response, skipping");
+        return;
+      }
+
+      const responseData = await response.json();
+      console.log("Response data", responseData);
+      if (responseData["curation_id"]) {
+        this.currentCurationId = responseData["curation_id"];
+      }
+
+      // There may be more to send, wait a second and see
+      setTimeout(this.__sendToApi.bind(this), 1000);
+    }
+    this.sending = false;
+  }
+}, { extends: 'li' });
+

+ 5 - 1
front-end/src/components/organisms/search-bar.js

@@ -144,8 +144,12 @@ export default define('search-bar', class extends HTMLElement {
 
 
     // Focus search bar when pressing `ctrl + k` or `/`
     // Focus search bar when pressing `ctrl + k` or `/`
     document.addEventListener('keydown', (e) => {
     document.addEventListener('keydown', (e) => {
-      if ((e.key === 'k' && e.ctrlKey) || e.key === '/' || e.key === 'Escape') {
+      if ((e.key === 'k' && e.ctrlKey) || e.key === 'Escape') {
         e.preventDefault();
         e.preventDefault();
+
+        // Remove the modal if it's visible
+        document.querySelector('.modal').style.display = 'none';
+
         this.searchInput.focus();
         this.searchInput.focus();
       }
       }
     });
     });

+ 84 - 0
front-end/src/components/register.js

@@ -0,0 +1,84 @@
+import define from '../utils/define.js';
+import config from "../../config.js";
+
+const template = () => /*html*/`
+  <form>
+    <h5>Register</h5>
+    <div>
+      <label for="register-email">Email</label>
+      <div>
+        <input class="form-control" type="text" id="register-email" autocomplete="email" required="" minlength="3">
+      </div>
+      <label for="register-username">Username</label>
+      <div>
+        <input class="form-control" type="text" id="register-username" autocomplete="username" required="" minlength="3">
+      </div>
+    </div>
+    <div>
+      <label for="register-password">Password</label>
+      <div>
+        <input type="password" id="register-password" autocomplete="password" required="" maxlength="60">
+      </div>
+    </div>
+    <div>
+      <label for="register-password">Confirm password</label>
+      <div>
+        <input type="password" id="register-password-verify" autocomplete="confirm password" required="" maxlength="60">
+      </div>
+    </div>
+    <div>
+      <button class="btn btn-secondary" type="submit">Register</button>
+    </div>
+  </form>
+`;
+
+export default define('register', class extends HTMLElement {
+  constructor() {
+    super();
+    this.registerForm = null;
+    this.emailInput = null;
+    this.usernameInput = null;
+    this.passwordInput = null;
+    this.passwordVerifyInput = null;
+    this.__setup();
+    this.__events();
+  }
+
+  __setup() {
+    this.innerHTML = template();
+    this.registerForm = this.querySelector('form');
+    this.emailInput = this.querySelector('#register-email');
+    this.usernameInput = this.querySelector('#register-username');
+    this.passwordInput = this.querySelector('#register-password');
+    this.passwordVerifyInput = this.querySelector('#register-password-verify');
+  }
+
+  __events() {
+    this.registerForm.addEventListener('submit', (e) => {
+      e.preventDefault();
+      this.__handleRegister(e);
+    });
+  }
+
+  __handleRegister = async () => {
+    const response = await fetch(`${config.publicApiURL}user/register`, {
+        method: 'POST',
+        cache: 'no-cache',
+        headers: {'Content-Type': 'application/json'},
+        body: JSON.stringify({
+          "username": this.usernameInput.value,
+          "email": this.emailInput.value,
+          "password": this.passwordInput.value,
+          "password_verify": this.passwordVerifyInput.value,
+        })
+      });
+    if (response.status === 200) {
+      const registerData = await response.json();
+      console.log("Register data", registerData);
+      document.cookie = `jwt=${registerData["jwt"]}; SameSite=Strict`;
+      console.log("Register success");
+    } else {
+      console.log("Register error", response);
+    }
+  }
+});

+ 10 - 3
front-end/src/index.html

@@ -35,21 +35,28 @@
   <script src="https://unpkg.com/@ungap/custom-elements" type="module"></script>
   <script src="https://unpkg.com/@ungap/custom-elements" type="module"></script>
 
 
   <!-- OpenSearch -->
   <!-- OpenSearch -->
-  <link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="MWMBL Search">
+  <link rel="search" type="application/opensearchdescription+xml" href="../assets/opensearch.xml" title="MWMBL Search">
+
+  <!-- POC temporary use of jQueryUI! -->
+  <link rel="stylesheet" href="//code.jquery.com/ui/1.13.2/themes/base/jquery-ui.css">
+  <script src="https://code.jquery.com/jquery-3.6.0.js"></script>
+  <script src="https://code.jquery.com/ui/1.13.2/jquery-ui.js"></script>
 </head>
 </head>
 
 
 <body>
 <body>
+  <mwmbl-login></mwmbl-login>
+  <mwmbl-register></mwmbl-register>
   <mwmbl-app></mwmbl-app>
   <mwmbl-app></mwmbl-app>
   <noscript>
   <noscript>
     <main class="noscript">
     <main class="noscript">
-      <img class="brand-icon" src="/static/images/logo.svg" width="40" height="40" alt="mwmbl logo">
+      <img class="brand-icon" src="/images/logo.svg" width="40" height="40" alt="mwmbl logo">
       <h1>
       <h1>
         Welcome to mwmbl, the free, open-source and non-profit search engine.
         Welcome to mwmbl, the free, open-source and non-profit search engine.
       </h1>
       </h1>
       <p>This website requires you to support/enable scripts.</p>
       <p>This website requires you to support/enable scripts.</p>
       <p>
       <p>
         More information on
         More information on
-        <a href="https://github.com/mwmbl/mwmbl" target="_blank">
+        <a href="https://github.com/mwmbl/mwmbl" target="__blank">
           Github
           Github
         </a>
         </a>
         .
         .

+ 3 - 0
front-end/src/index.js

@@ -15,8 +15,11 @@
   if (!redirected) {
   if (!redirected) {
     // Load components only after redirects are checked.
     // Load components only after redirects are checked.
     import('./components/app.js');
     import('./components/app.js');
+    import('./components/login.js');
+    import('./components/register.js');
     import("./components/organisms/search-bar.js");
     import("./components/organisms/search-bar.js");
     import("./components/organisms/results.js");
     import("./components/organisms/results.js");
     import("./components/organisms/footer.js");
     import("./components/organisms/footer.js");
+    import("./components/organisms/save.js");
   }
   }
 })();
 })();

+ 6 - 6
front-end/src/stats/index.html

@@ -5,18 +5,18 @@
   <title>Mwmbl Stats</title>
   <title>Mwmbl Stats</title>
 
 
   <!-- Favicons -->
   <!-- Favicons -->
-  <link rel="icon" href="/images/favicon.svg" type="image/svg+xml">
+  <link rel="icon" href="/static/images/favicon.svg" type="image/svg+xml">
 
 
   <!-- Fonts import -->
   <!-- Fonts import -->
-  <link rel="preload" href="/fonts/inter/inter.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
+  <link rel="preload" href="/static/fonts/inter/inter.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
   <noscript>
   <noscript>
-    <link rel="stylesheet" href="/fonts/inter/inter.css">
+    <link rel="stylesheet" href="/static/fonts/inter/inter.css">
   </noscript>
   </noscript>
 
 
   <!-- CSS Stylesheets (this is critical CSS) -->
   <!-- CSS Stylesheets (this is critical CSS) -->
-  <link rel="stylesheet" type="text/css" href="/css/reset.css">
-  <link rel="stylesheet" type="text/css" href="/css/theme.css">
-  <link rel="stylesheet" type="text/css" href="/css/global.css">
+  <link rel="stylesheet" type="text/css" href="/static/css/reset.css">
+  <link rel="stylesheet" type="text/css" href="/static/css/theme.css">
+  <link rel="stylesheet" type="text/css" href="/static/css/global.css">
   <link rel="stylesheet" type="text/css" href="stats.css">
   <link rel="stylesheet" type="text/css" href="stats.css">
 </head>
 </head>
 <body>
 <body>

+ 1 - 8
front-end/vite.config.js

@@ -1,18 +1,11 @@
 import legacy from '@vitejs/plugin-legacy'
 import legacy from '@vitejs/plugin-legacy'
-import { resolve } from 'path'
 
 
 export default {
 export default {
   root: './src',
   root: './src',
   base: '/static',
   base: '/static',
   publicDir: '../assets',
   publicDir: '../assets',
   build: {
   build: {
-    outDir: '../dist',
-    rollupOptions: {
-      input: {
-        main: resolve(__dirname, 'src/index.html'),
-        stats: resolve(__dirname, 'src/stats/index.html'),
-      },
-    },
+    outDir: '../dist'
   },
   },
   plugins: [
   plugins: [
     legacy({
     legacy({

部分文件因为文件数量过多而无法显示