Quellcode durchsuchen

Merge pull request #122 from mwmbl/login-ui

Login UI
Daoud Clarke vor 1 Jahr
Ursprung
Commit
95f9c56ba6
46 geänderte Dateien mit 1372 neuen und 330 gelöschten Zeilen
  1. 1 1
      Dockerfile
  2. 94 0
      front-end/assets/css/global.css
  3. 5 5
      front-end/assets/opensearch.xml
  4. 2 1
      front-end/config.js
  5. 20 62
      front-end/package-lock.json
  6. 0 3
      front-end/package.json
  7. 10 3
      front-end/src/components/app.js
  8. 69 0
      front-end/src/components/login.js
  9. 24 0
      front-end/src/components/molecules/add-button.js
  10. 69 0
      front-end/src/components/molecules/add-result.js
  11. 35 0
      front-end/src/components/molecules/delete-button.js
  12. 17 5
      front-end/src/components/molecules/result.js
  13. 53 0
      front-end/src/components/molecules/validate-button.js
  14. 5 5
      front-end/src/components/organisms/footer.js
  15. 168 4
      front-end/src/components/organisms/results.js
  16. 112 0
      front-end/src/components/organisms/save.js
  17. 5 1
      front-end/src/components/organisms/search-bar.js
  18. 84 0
      front-end/src/components/register.js
  19. 9 2
      front-end/src/index.html
  20. 3 0
      front-end/src/index.js
  21. 6 6
      front-end/src/stats/index.html
  22. 1 8
      front-end/vite.config.js
  23. 8 0
      mwmbl/admin.py
  24. 7 1
      mwmbl/api.py
  25. 8 7
      mwmbl/apps.py
  26. 5 0
      mwmbl/main.py
  27. 58 0
      mwmbl/migrations/0001_initial.py
  28. 0 0
      mwmbl/migrations/__init__.py
  29. 15 0
      mwmbl/models.py
  30. 80 0
      mwmbl/platform/curate.py
  31. 46 0
      mwmbl/platform/data.py
  32. 0 190
      mwmbl/platform/user.py
  33. 1 1
      mwmbl/settings_bg_prod.py
  34. 22 16
      mwmbl/settings_common.py
  35. 19 1
      mwmbl/settings_dev.py
  36. 26 2
      mwmbl/settings_prod.py
  37. 22 0
      mwmbl/templates/base.html
  38. 5 0
      mwmbl/templates/home.html
  39. 8 0
      mwmbl/templates/profile.html
  40. 26 0
      mwmbl/templates/registration/login.html
  41. 10 0
      mwmbl/templates/signup.html
  42. 12 2
      mwmbl/urls.py
  43. 24 0
      mwmbl/views.py
  44. 3 3
      nginx.conf.sigil
  45. 173 1
      poetry.lock
  46. 2 0
      pyproject.toml

+ 1 - 1
Dockerfile

@@ -50,7 +50,7 @@ COPY --from=builder /venv /venv
 COPY --from=front-end /front-end/dist /front-end-build
 COPY --from=front-end /front-end/dist /front-end-build
 
 
 ADD nginx.conf.sigil /app
 ADD nginx.conf.sigil /app
-ADD app.json /app
+# ADD app.json /app
 
 
 # Set up a volume where the data will live
 # Set up a volume where the data will live
 VOLUME ["/data"]
 VOLUME ["/data"]

+ 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;
 }
 }

Datei-Diff unterdrückt, da er zu groß ist
+ 5 - 5
front-end/assets/opensearch.xml


+ 2 - 1
front-end/config.js

@@ -8,7 +8,8 @@
 
 
 export default {
 export default {
   componentPrefix: 'mwmbl',
   componentPrefix: 'mwmbl',
-  publicApiURL: 'https://api.mwmbl.org/',
+  publicApiURL: '/api/v1/',
+  // 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 - 3
front-end/src/components/app.js

@@ -1,16 +1,23 @@
 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><a href="/accounts/login/">Login</a> <a href="/accounts/signup/">Sign up</a> </div>
     <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="/static/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' });

+ 168 - 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,173 @@ 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: {
+            timestamp: Date.now(),
+            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: {
+            timestamp: Date.now(),
+            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: {
+          timestamp: Date.now(),
+          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: {
+            timestamp: Date.now(),
+            url: document.location.href,
+            results: results,
+            curation: {}
+          }
+        }
+      });
+      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: {
+          timestamp: Date.now(),
+          url: document.location.href,
+          results: newResults,
+          curation: {
+            old_index: this.oldIndex,
+            new_index: newIndex,
+          }
+        }
+      }
+    });
+    globalBus.dispatch(curationMoveEvent);
   }
   }
 });
 });

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

@@ -0,0 +1,112 @@
+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 + "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 csrftoken = document.cookie
+      .split('; ')
+      .find((row) => row.startsWith('csrftoken='))
+      ?.split('=')[1];
+
+    if (!csrftoken) {
+      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'];
+
+      const data = value['data'];
+      console.log("Data", data);
+      const response = await fetch(url, {
+          method: 'POST',
+          cache: 'no-cache',
+          headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrftoken},
+          credentials: "same-origin",
+          mode: "same-origin",
+          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);
+      // 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);
+    }
+  }
+});

+ 9 - 2
front-end/src/index.html

@@ -35,10 +35,17 @@
   <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">
@@ -49,7 +56,7 @@
       <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({

+ 8 - 0
mwmbl/admin.py

@@ -0,0 +1,8 @@
+from django.contrib.admin import ModelAdmin
+from django.contrib.auth.admin import UserAdmin
+from django.contrib import admin
+
+from mwmbl.models import MwmblUser, UserCuration
+
+admin.site.register(MwmblUser, UserAdmin)
+admin.site.register(UserCuration, ModelAdmin)

+ 7 - 1
mwmbl/api.py

@@ -3,10 +3,12 @@ from pathlib import Path
 
 
 from django.conf import settings
 from django.conf import settings
 from ninja import NinjaAPI
 from ninja import NinjaAPI
+from ninja.security import django_auth
 
 
 import mwmbl.crawler.app as crawler
 import mwmbl.crawler.app as crawler
 from mwmbl.indexer.batch_cache import BatchCache
 from mwmbl.indexer.batch_cache import BatchCache
 from mwmbl.indexer.paths import INDEX_NAME, BATCH_DIR_NAME
 from mwmbl.indexer.paths import INDEX_NAME, BATCH_DIR_NAME
+from mwmbl.platform import curate
 from mwmbl.tinysearchengine import search
 from mwmbl.tinysearchengine import search
 from mwmbl.tinysearchengine.completer import Completer
 from mwmbl.tinysearchengine.completer import Completer
 from mwmbl.tinysearchengine.indexer import TinyIndex, Document
 from mwmbl.tinysearchengine.indexer import TinyIndex, Document
@@ -24,13 +26,17 @@ batch_cache = BatchCache(Path(settings.DATA_PATH) / BATCH_DIR_NAME)
 
 
 
 
 def create_api(version):
 def create_api(version):
-    api = NinjaAPI(version=version)
+    # Set csrf to True to all cookie-based authentication
+    api = NinjaAPI(version=version, csrf=True)
 
 
     search_router = search.create_router(ranker)
     search_router = search.create_router(ranker)
     api.add_router("/search/", search_router)
     api.add_router("/search/", search_router)
 
 
     crawler_router = crawler.create_router(batch_cache=batch_cache, queued_batches=queued_batches)
     crawler_router = crawler.create_router(batch_cache=batch_cache, queued_batches=queued_batches)
     api.add_router("/crawler/", crawler_router)
     api.add_router("/crawler/", crawler_router)
+
+    curation_router = curate.create_router(index_path)
+    api.add_router("/curation/", curation_router, auth=django_auth)
     return api
     return api
 
 
 
 

+ 8 - 7
mwmbl/apps.py

@@ -6,19 +6,20 @@ from pathlib import Path
 from django.apps import AppConfig
 from django.apps import AppConfig
 from django.conf import settings
 from django.conf import settings
 
 
-from mwmbl.api import queued_batches
-from mwmbl import background
-from mwmbl.indexer.paths import INDEX_NAME
-from mwmbl.indexer.update_urls import update_urls_continuously
-from mwmbl.tinysearchengine.indexer import TinyIndex, Document, PAGE_SIZE
-from mwmbl.url_queue import update_queue_continuously
-
 
 
 class MwmblConfig(AppConfig):
 class MwmblConfig(AppConfig):
     name = "mwmbl"
     name = "mwmbl"
     verbose_name = "Mwmbl Application"
     verbose_name = "Mwmbl Application"
 
 
     def ready(self):
     def ready(self):
+        # Imports here to avoid AppRegistryNotReady exception
+        from mwmbl.api import queued_batches
+        from mwmbl import background
+        from mwmbl.indexer.paths import INDEX_NAME
+        from mwmbl.indexer.update_urls import update_urls_continuously
+        from mwmbl.tinysearchengine.indexer import TinyIndex, Document, PAGE_SIZE
+        from mwmbl.url_queue import update_queue_continuously
+
         index_path = Path(settings.DATA_PATH) / INDEX_NAME
         index_path = Path(settings.DATA_PATH) / INDEX_NAME
         try:
         try:
             existing_index = TinyIndex(item_factory=Document, index_path=index_path)
             existing_index = TinyIndex(item_factory=Document, index_path=index_path)

+ 5 - 0
mwmbl/main.py

@@ -1,7 +1,12 @@
+import django
 import uvicorn
 import uvicorn
+from django.core.management import call_command
 
 
 
 
 def run():
 def run():
+    django.setup()
+    call_command("collectstatic", "--clear", "--noinput")
+    call_command("migrate")
     uvicorn.run("mwmbl.asgi:application", host="0.0.0.0", port=5000)
     uvicorn.run("mwmbl.asgi:application", host="0.0.0.0", port=5000)
 
 
 
 

+ 58 - 0
mwmbl/migrations/0001_initial.py

@@ -0,0 +1,58 @@
+# Generated by Django 4.2.6 on 2023-10-25 11:55
+
+from django.conf import settings
+import django.contrib.auth.models
+import django.contrib.auth.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('auth', '0012_alter_user_first_name_max_length'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='MwmblUser',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('password', models.CharField(max_length=128, verbose_name='password')),
+                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+                ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
+                ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
+                ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+                ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
+                ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+                ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+                ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
+                ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
+            ],
+            options={
+                'verbose_name': 'user',
+                'verbose_name_plural': 'users',
+                'abstract': False,
+            },
+            managers=[
+                ('objects', django.contrib.auth.models.UserManager()),
+            ],
+        ),
+        migrations.CreateModel(
+            name='UserCuration',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('timestamp', models.DateTimeField()),
+                ('url', models.CharField(max_length=300)),
+                ('results', models.JSONField()),
+                ('curation_type', models.CharField(max_length=20)),
+                ('curation', models.JSONField()),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]

+ 0 - 0
mwmbl/migrations/__init__.py


+ 15 - 0
mwmbl/models.py

@@ -0,0 +1,15 @@
+from django.db import models
+from django.contrib.auth.models import AbstractUser
+
+
+class MwmblUser(AbstractUser):
+    pass
+
+
+class UserCuration(models.Model):
+    user = models.ForeignKey(MwmblUser, on_delete=models.CASCADE)
+    timestamp = models.DateTimeField()
+    url = models.CharField(max_length=300)
+    results = models.JSONField()
+    curation_type = models.CharField(max_length=20)
+    curation = models.JSONField()

+ 80 - 0
mwmbl/platform/curate.py

@@ -0,0 +1,80 @@
+from typing import Any
+from urllib.parse import parse_qs
+
+from ninja import Router
+
+from mwmbl.indexer.update_urls import get_datetime_from_timestamp
+from mwmbl.models import UserCuration
+from mwmbl.platform.data import CurateBegin, CurateMove, CurateDelete, CurateAdd, CurateValidate, \
+    make_curation_type
+from mwmbl.tinysearchengine.indexer import TinyIndex, Document
+from mwmbl.tokenizer import tokenize
+
+RESULT_URL = "https://mwmbl.org/?q="
+MAX_CURATED_SCORE = 1_111_111.0
+
+
+def create_router(index_path: str) -> Router:
+    router = Router(tags=["user"])
+
+    @router.post("/begin")
+    def user_begin_curate(request, curate_begin: make_curation_type(CurateBegin)):
+        return _curate(request, "curate_begin", curate_begin)
+
+    @router.post("/move")
+    def user_move_result(request, curate_move: make_curation_type(CurateMove)):
+        return _curate(request, "curate_move", curate_move)
+
+    @router.post("/delete")
+    def user_delete_result(request, curate_delete: make_curation_type(CurateDelete)):
+        return _curate(request, "curate_delete", curate_delete)
+
+    @router.post("/add")
+    def user_add_result(request, curate_add: make_curation_type(CurateAdd)):
+        return _curate(request, "curate_add", curate_add)
+
+    @router.post("/validate")
+    def user_add_result(request, curate_validate: make_curation_type(CurateValidate)):
+        return _curate(request, "curate_validate", curate_validate)
+
+    def _curate(request, curation_type: str, curation: Any):
+        user_curation = UserCuration(
+            user=request.user,
+            timestamp=get_datetime_from_timestamp(curation.timestamp / 1000.0),
+            url=curation.url,
+            results=curation.dict()["results"],
+            curation_type=curation_type,
+            curation=curation.curation.dict(),
+        )
+        user_curation.save()
+
+        with TinyIndex(Document, index_path, 'w') as indexer:
+            query_string = parse_qs(curation.url)
+            if len(query_string) > 1:
+                raise ValueError(f"Should be one query string in the URL: {curation.url}")
+
+            queries = next(iter(query_string.values()))
+            if len(queries) > 1:
+                raise ValueError(f"Should be one query value in the URL: {curation.url}")
+
+            query = queries[0]
+            print("Query", query)
+            tokens = tokenize(query)
+            print("Tokens", tokens)
+            term = " ".join(tokens)
+            print("Key", term)
+
+            documents = [
+                Document(result.title, result.url, result.extract, MAX_CURATED_SCORE - i, term, result.curated)
+                for i, result in enumerate(curation.results)
+            ]
+            page_index = indexer.get_key_page_index(term)
+            print("Page index", page_index)
+            print("Storing documents", documents)
+            indexer.store_in_page(page_index, documents)
+
+        return {"curation": "ok"}
+
+    return router
+
+

+ 46 - 0
mwmbl/platform/data.py

@@ -0,0 +1,46 @@
+from datetime import datetime
+from typing import TypeVar, Generic
+
+from ninja import Schema
+
+
+class Result(Schema):
+    url: str
+    title: str
+    extract: str
+    curated: bool
+
+
+class CurateBegin(Schema):
+    pass
+
+
+class CurateMove(Schema):
+    old_index: int
+    new_index: int
+
+
+class CurateDelete(Schema):
+    delete_index: int
+
+
+class CurateAdd(Schema):
+    insert_index: int
+    url: str
+
+
+class CurateValidate(Schema):
+    validate_index: int
+    is_validated: bool
+
+
+T = TypeVar('T', CurateBegin, CurateAdd, CurateDelete, CurateMove, CurateValidate)
+
+
+def make_curation_type(t):
+    class Curation(Schema):
+        timestamp: int
+        url: str
+        results: list[Result]
+        curation: t
+    return Curation

+ 0 - 190
mwmbl/platform/user.py

@@ -1,190 +0,0 @@
-import json
-import os
-from typing import TypeVar, Generic
-from urllib.parse import urljoin, parse_qs
-
-import requests
-from fastapi import APIRouter, Response
-from pydantic import BaseModel
-
-from mwmbl.tinysearchengine.indexer import TinyIndex, Document
-from mwmbl.tokenizer import tokenize
-
-
-LEMMY_URL = os.environ["LEMMY_URL"]
-RESULT_URL = "https://mwmbl.org/?q="
-MAX_CURATED_SCORE = 1_111_111.0
-
-
-class Register(BaseModel):
-    username: str
-    email: str
-    password: str
-    password_verify: str
-
-
-class Login(BaseModel):
-    username_or_email: str
-    password: str
-
-
-class Result(BaseModel):
-    url: str
-    title: str
-    extract: str
-    curated: bool
-
-
-class BeginCurate(BaseModel):
-    auth: str
-    url: str
-    results: list[Result]
-
-
-class CurateMove(BaseModel):
-    old_index: int
-    new_index: int
-
-
-class CurateDelete(BaseModel):
-    delete_index: int
-
-
-class CurateAdd(BaseModel):
-    insert_index: int
-    url: str
-
-
-class CurateValidate(BaseModel):
-    validate_index: int
-    is_validated: bool
-
-
-T = TypeVar('T',  CurateAdd, CurateDelete, CurateMove, CurateValidate)
-
-
-class Curation(BaseModel, Generic[T]):
-    auth: str
-    curation_id: int
-    url: str
-    results: list[Result]
-    curation: T
-
-
-def create_router(index_path: str) -> APIRouter:
-    router = APIRouter(prefix="/user", tags=["user"])
-
-    # TODO: reinstate
-    # community_id = get_community_id()
-    community_id = 0
-
-    @router.post("/register")
-    def user_register(register: Register) -> Response:
-        lemmy_register = {
-            "username": register.username,
-            "email": register.email,
-            "password": register.password,
-            "password_verify": register.password_verify,
-            "answer": "not applicable",
-            "captcha_answer": None,
-            "captcha_uuid": None,
-            "honeypot": None,
-            "show_nsfw": False,
-        }
-        request = requests.post(urljoin(LEMMY_URL, "api/v3/user/register"), json=lemmy_register)
-        if request.status_code != 200:
-            return Response(content=request.content, status_code=request.status_code, media_type="text/json")
-
-    @router.post("/login")
-    def user_login(login: Login) -> Response:
-        request = requests.post(urljoin(LEMMY_URL, "api/v3/user/login"), json=login.dict())
-        return Response(content=request.content, status_code=request.status_code, media_type="text/json")
-
-    @router.post("/curation/begin")
-    def user_begin_curate(begin_curate: BeginCurate):
-        results = begin_curate.dict()["results"]
-        body = json.dumps({"original_results": results}, indent=2)
-        create_post = {
-            "auth": begin_curate.auth,
-            "body": body,
-            "community_id": community_id,
-            "honeypot": None,
-            "language_id": None,
-            "name": begin_curate.url,
-            "nsfw": None,
-            "url": begin_curate.url,
-        }
-        request = requests.post(urljoin(LEMMY_URL, "api/v3/post"), json=create_post)
-        if request.status_code != 200:
-            return Response(content=request.content, status_code=request.status_code, media_type="text/json")
-        data = request.json()
-        curation_id = data["post_view"]["post"]["id"]
-        return {"curation_id": curation_id}
-
-    @router.post("/curation/move")
-    def user_move_result(curate_move: Curation[CurateMove]):
-        return _curate("curate_move", curate_move)
-
-    @router.post("/curation/delete")
-    def user_delete_result(curate_delete: Curation[CurateDelete]):
-        return _curate("curate_delete", curate_delete)
-
-    @router.post("/curation/add")
-    def user_add_result(curate_add: Curation[CurateAdd]):
-        return _curate("curate_add", curate_add)
-
-    @router.post("/curation/validate")
-    def user_add_result(curate_validate: Curation[CurateValidate]):
-        return _curate("curate_validate", curate_validate)
-
-    def _curate(curation_type: str, curation: Curation):
-        content = json.dumps({
-            "curation_type": curation_type,
-            "curation": curation.curation.dict(),
-        }, indent=2)
-        create_comment = {
-            "auth": curation.auth,
-            "content": json.dumps(content, indent=2),
-            "form_id": None,
-            "language_id": None,
-            "parent_id": None,
-            "post_id": curation.curation_id,
-        }
-        request = requests.post(urljoin(LEMMY_URL, "api/v3/comment"), json=create_comment)
-
-        with TinyIndex(Document, index_path, 'w') as indexer:
-            query_string = parse_qs(curation.url)
-            if len(query_string) > 1:
-                raise ValueError(f"Should be one query string in the URL: {curation.url}")
-
-            queries = next(iter(query_string.values()))
-            if len(queries) > 1:
-                raise ValueError(f"Should be one query value in the URL: {curation.url}")
-
-            query = queries[0]
-            print("Query", query)
-            tokens = tokenize(query)
-            print("Tokens", tokens)
-            term = " ".join(tokens)
-            print("Key", term)
-
-            documents = [
-                Document(result.title, result.url, result.extract, MAX_CURATED_SCORE - i, term, result.curated)
-                for i, result in enumerate(curation.results)
-            ]
-            page_index = indexer.get_key_page_index(term)
-            print("Page index", page_index)
-            print("Storing documents", documents)
-            indexer.store_in_page(page_index, documents)
-
-        return Response(content=request.content, status_code=request.status_code, media_type="text/json")
-
-    return router
-
-
-def get_community_id() -> str:
-    request = requests.get(urljoin(LEMMY_URL, "api/v3/community?name=main"))
-    community = request.json()
-    return community["community_view"]["community"]["id"]
-
-

+ 1 - 1
mwmbl/settings_bg_prod.py

@@ -5,4 +5,4 @@ ALLOWED_HOSTS = ["api.mwmbl.org", "mwmbl.org"]
 
 
 DATA_PATH = "/app/storage"
 DATA_PATH = "/app/storage"
 RUN_BACKGROUND_PROCESSES = True
 RUN_BACKGROUND_PROCESSES = True
-NUM_PAGES = 10240000
+NUM_PAGES = 10240000

+ 22 - 16
mwmbl/settings_common.py

@@ -19,9 +19,6 @@ BASE_DIR = Path(__file__).resolve().parent.parent
 # Quick-start development settings - unsuitable for production
 # Quick-start development settings - unsuitable for production
 # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
 # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
 
 
-# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = 'django-insecure-qqr#f(i3uf%m8%8u35vn=ov-uk(*8!a&1t-hxa%ev2^t1%j&sm'
-
 
 
 # Application definition
 # Application definition
 
 
@@ -33,6 +30,10 @@ INSTALLED_APPS = [
     'django.contrib.messages',
     'django.contrib.messages',
     'django.contrib.staticfiles',
     'django.contrib.staticfiles',
     'mwmbl',
     'mwmbl',
+
+    'allauth',
+    'allauth.account',
+    'allauth.socialaccount',
 ]
 ]
 
 
 MIDDLEWARE = [
 MIDDLEWARE = [
@@ -43,6 +44,8 @@ MIDDLEWARE = [
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
+
+    "allauth.account.middleware.AccountMiddleware",
 ]
 ]
 
 
 ROOT_URLCONF = 'mwmbl.urls'
 ROOT_URLCONF = 'mwmbl.urls'
@@ -66,17 +69,6 @@ TEMPLATES = [
 WSGI_APPLICATION = 'mwmbl.wsgi.application'
 WSGI_APPLICATION = 'mwmbl.wsgi.application'
 
 
 
 
-# Database
-# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
-
-DATABASES = {
-    'default': {
-        'ENGINE': 'django.db.backends.sqlite3',
-        'NAME': BASE_DIR / 'db.sqlite3',
-    }
-}
-
-
 # Password validation
 # Password validation
 # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
 # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
 
 
@@ -112,11 +104,25 @@ USE_TZ = True
 # https://docs.djangoproject.com/en/4.2/howto/static-files/
 # https://docs.djangoproject.com/en/4.2/howto/static-files/
 
 
 STATIC_URL = 'static/'
 STATIC_URL = 'static/'
-STATICFILES_DIRS = [str(Path(__file__).parent.parent / "front-end" / "dist")]
-print("Static files", STATICFILES_DIRS)
 
 
 # Default primary key field type
 # Default primary key field type
 # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
 # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
 
 
 DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
 DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
 
 
+AUTHENTICATION_BACKENDS = [
+    # Needed to login by username in Django admin, regardless of `allauth`
+    'django.contrib.auth.backends.ModelBackend',
+
+    # `allauth` specific authentication methods, such as login by email
+    'allauth.account.auth_backends.AuthenticationBackend',
+]
+
+
+AUTH_USER_MODEL = "mwmbl.MwmblUser"
+
+
+ACCOUNT_EMAIL_REQUIRED = True
+ACCOUNT_EMAIL_VERIFICATION = "mandatory"
+
+DEFAULT_FROM_EMAIL = "admin@mwmbl.org"

+ 19 - 1
mwmbl/settings_dev.py

@@ -1,9 +1,27 @@
 from mwmbl.settings_common import *
 from mwmbl.settings_common import *
 
 
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'django-insecure-qqr#f(i3uf%m8%8u35vn=ov-uk(*8!a&1t-hxa%ev2^t1%j&sm'
+
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': BASE_DIR / 'db.sqlite3',
+    }
+}
+
+
+STATICFILES_DIRS = [str(Path(__file__).parent.parent / "front-end" / "dist")]
+
+
 DEBUG = True
 DEBUG = True
 ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
 ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
 
 
+EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
+
 DATA_PATH = "./devdata"
 DATA_PATH = "./devdata"
-RUN_BACKGROUND_PROCESSES = True
+RUN_BACKGROUND_PROCESSES = False
 NUM_PAGES = 2560
 NUM_PAGES = 2560
 
 

+ 26 - 2
mwmbl/settings_prod.py

@@ -1,7 +1,31 @@
+import os
+
+import dj_database_url
+
 from mwmbl.settings_common import *
 from mwmbl.settings_common import *
 
 
-DEBUG = False
-ALLOWED_HOSTS = ["api.mwmbl.org", "mwmbl.org"]
+
+SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
+
+
+STATIC_ROOT = "/app/static/"
+STATICFILES_DIRS = ["/front-end-build/"]
+
+
+DATABASES = {'default': dj_database_url.config(default=os.environ["DATABASE_URL"])}
+
+DEBUG = True    # TODO set back to False
+ALLOWED_HOSTS = ["api.mwmbl.org", "mwmbl.org", "beta.mwmbl.org"]
+CSRF_TRUSTED_ORIGINS = [f"https://{domain}" for domain in ALLOWED_HOSTS]
+
+
+# Sendgrid email settings
+EMAIL_HOST = 'smtp.sendgrid.net'
+EMAIL_HOST_USER = 'apikey'
+EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
+EMAIL_PORT = 587
+EMAIL_USE_TLS = True
+
 
 
 DATA_PATH = "/app/storage"
 DATA_PATH = "/app/storage"
 RUN_BACKGROUND_PROCESSES = False
 RUN_BACKGROUND_PROCESSES = False

+ 22 - 0
mwmbl/templates/base.html

@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>{% block title %}Simple is Better Than Complex{% endblock %}</title>
+  </head>
+  <body>
+    <header>
+      <h1>My Site</h1>
+      {% if user.is_authenticated %}
+        <a href="{% url 'account_logout' %}">logout</a>
+      {% else %}
+        <a href="{% url 'account_login' %}">login</a> / <a href="{% url 'signup' %}">signup</a>
+      {% endif %}
+      <hr>
+    </header>
+    <main>
+      {% block content %}
+      {% endblock %}
+    </main>
+  </body>
+</html>

+ 5 - 0
mwmbl/templates/home.html

@@ -0,0 +1,5 @@
+{% extends 'base.html' %}
+
+{% block content %}
+  <h2>Welcome, {{ user.username }}!</h2>
+{% endblock %}

+ 8 - 0
mwmbl/templates/profile.html

@@ -0,0 +1,8 @@
+{% extends "base.html" %}
+{% block title %}Profile Page{% endblock title %}
+{% block content %}
+    <div class="row my-3 p-3">
+        <h1>This is the profile page for {{user.username}}</h1>
+    </div>
+
+{% endblock content %}

+ 26 - 0
mwmbl/templates/registration/login.html

@@ -0,0 +1,26 @@
+{% extends 'base.html' %}
+
+{% block content %}
+  <h2>Log in to My Site</h2>
+  {% if form.errors %}
+    <p style="color: red">Your username and password didn't match. Please try again.</p>
+  {% endif %}
+  <form method="post">
+    {% csrf_token %}
+    <input type="hidden" name="next" value="{{ next }}" />
+    {% for field in form %}
+      <p>
+        {{ field.label_tag }}<br>
+        {{ field }}<br>
+        {% for error in field.errors %}
+          <p style="color: red">{{ error }}</p>
+        {% endfor %}
+        {% if field.help_text %}
+          <p><small style="color: grey">{{ field.help_text }}</small></p>
+        {% endif %}
+      </p>
+    {% endfor %}
+    <button type="submit">Log in</button>
+    <a href="{% url 'signup' %}">New to My Site? Sign up</a>
+  </form>
+{% endblock %}

+ 10 - 0
mwmbl/templates/signup.html

@@ -0,0 +1,10 @@
+{% extends 'base.html' %}
+
+{% block content %}
+  <h2>Sign up</h2>
+  <form method="post">
+    {% csrf_token %}
+    {{ form.as_p }}
+    <button type="submit">Sign up</button>
+  </form>
+{% endblock %}

+ 12 - 2
mwmbl/urls.py

@@ -15,12 +15,22 @@ Including another URLconf
     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
 """
 """
 from django.contrib import admin
 from django.contrib import admin
-from django.urls import path
+from django.contrib.auth import login, logout
+from django.template.defaulttags import url
+from django.urls import path, include
 
 
 from mwmbl.api import api_original as api, api_v1
 from mwmbl.api import api_original as api, api_v1
+from mwmbl.views import signup, profile
 
 
 urlpatterns = [
 urlpatterns = [
     path('admin/', admin.site.urls),
     path('admin/', admin.site.urls),
     path('', api.urls),
     path('', api.urls),
-    path('api/v1/', api_v1.urls)
+    path('api/v1/', api_v1.urls),
+    path('accounts/', include('allauth.urls')),
+
+    # path("accounts/", include("django.contrib.auth.urls")),
+    # path('accounts/new/', signup, name='signup'),
+    path('accounts/profile/', profile, name='profile'),
+    # path('login/', login, {'template_name': 'login.html'}, name='login'),
+    # path('logout/', logout, {'next_page': 'login'}, name='logout'),
 ]
 ]

+ 24 - 0
mwmbl/views.py

@@ -0,0 +1,24 @@
+from django.contrib.auth import authenticate, login
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.forms import UserCreationForm
+from django.shortcuts import redirect, render
+
+
+def signup(request):
+    if request.method == 'POST':
+        form = UserCreationForm(request.POST)
+        if form.is_valid():
+            form.save()
+            username = form.cleaned_data.get('username')
+            raw_password = form.cleaned_data.get('password1')
+            user = authenticate(username=username, password=raw_password)
+            login(request, user)
+            return redirect('/')
+    else:
+        form = UserCreationForm()
+    return render(request, 'signup.html', {'form': form})
+
+
+@login_required
+def profile(request):
+    return render(request, 'profile.html')

+ 3 - 3
nginx.conf.sigil

@@ -100,17 +100,17 @@ server {
 
 
   ## Static file hosting
   ## Static file hosting
   location /static/ {
   location /static/ {
-    alias /var/lib/dokku/data/storage/mwmbl/;
+    alias /var/lib/dokku/data/storage/mwmbl-beta/;
   }
   }
 
 
   ## Root and stats served statically
   ## Root and stats served statically
   location = / {
   location = / {
-    root /var/lib/dokku/data/storage/mwmbl;
+    root /var/lib/dokku/data/storage/mwmbl-beta;
     try_files /index.html =404;
     try_files /index.html =404;
   }
   }
 
 
   location ~ ^\/stats\/?$ {
   location ~ ^\/stats\/?$ {
-    root /var/lib/dokku/data/storage/mwmbl;
+    root /var/lib/dokku/data/storage/mwmbl-beta;
     try_files /stats/index.html =404;
     try_files /stats/index.html =404;
   }
   }
 
 

+ 173 - 1
poetry.lock

@@ -419,6 +419,52 @@ files = [
     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
     {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
 ]
 ]
 
 
+[[package]]
+name = "cryptography"
+version = "41.0.4"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:80907d3faa55dc5434a16579952ac6da800935cd98d14dbd62f6f042c7f5e839"},
+    {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35c00f637cd0b9d5b6c6bd11b6c3359194a8eba9c46d4e875a3660e3b400005f"},
+    {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cecfefa17042941f94ab54f769c8ce0fe14beff2694e9ac684176a2535bf9714"},
+    {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e40211b4923ba5a6dc9769eab704bdb3fbb58d56c5b336d30996c24fcf12aadb"},
+    {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:23a25c09dfd0d9f28da2352503b23e086f8e78096b9fd585d1d14eca01613e13"},
+    {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2ed09183922d66c4ec5fdaa59b4d14e105c084dd0febd27452de8f6f74704143"},
+    {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5a0f09cefded00e648a127048119f77bc2b2ec61e736660b5789e638f43cc397"},
+    {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9eeb77214afae972a00dee47382d2591abe77bdae166bda672fb1e24702a3860"},
+    {file = "cryptography-41.0.4-cp37-abi3-win32.whl", hash = "sha256:3b224890962a2d7b57cf5eeb16ccaafba6083f7b811829f00476309bce2fe0fd"},
+    {file = "cryptography-41.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:c880eba5175f4307129784eca96f4e70b88e57aa3f680aeba3bab0e980b0f37d"},
+    {file = "cryptography-41.0.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:004b6ccc95943f6a9ad3142cfabcc769d7ee38a3f60fb0dddbfb431f818c3a67"},
+    {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:86defa8d248c3fa029da68ce61fe735432b047e32179883bdb1e79ed9bb8195e"},
+    {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:37480760ae08065437e6573d14be973112c9e6dcaf5f11d00147ee74f37a3829"},
+    {file = "cryptography-41.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b5f4dfe950ff0479f1f00eda09c18798d4f49b98f4e2006d644b3301682ebdca"},
+    {file = "cryptography-41.0.4-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7e53db173370dea832190870e975a1e09c86a879b613948f09eb49324218c14d"},
+    {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5b72205a360f3b6176485a333256b9bcd48700fc755fef51c8e7e67c4b63e3ac"},
+    {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:93530900d14c37a46ce3d6c9e6fd35dbe5f5601bf6b3a5c325c7bffc030344d9"},
+    {file = "cryptography-41.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efc8ad4e6fc4f1752ebfb58aefece8b4e3c4cae940b0994d43649bdfce8d0d4f"},
+    {file = "cryptography-41.0.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3391bd8e6de35f6f1140e50aaeb3e2b3d6a9012536ca23ab0d9c35ec18c8a91"},
+    {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0d9409894f495d465fe6fda92cb70e8323e9648af912d5b9141d616df40a87b8"},
+    {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ac4f9ead4bbd0bc8ab2d318f97d85147167a488be0e08814a37eb2f439d5cf6"},
+    {file = "cryptography-41.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:047c4603aeb4bbd8db2756e38f5b8bd7e94318c047cfe4efeb5d715e08b49311"},
+    {file = "cryptography-41.0.4.tar.gz", hash = "sha256:7febc3094125fc126a7f6fb1f420d0da639f3f32cb15c8ff0dc3997c4549f51a"},
+]
+
+[package.dependencies]
+cffi = ">=1.12"
+
+[package.extras]
+docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
+docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"]
+nox = ["nox"]
+pep8test = ["black", "check-sdist", "mypy", "ruff"]
+sdist = ["build"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
+test-randomorder = ["pytest-randomly"]
+
 [[package]]
 [[package]]
 name = "cymem"
 name = "cymem"
 version = "2.0.8"
 version = "2.0.8"
@@ -462,6 +508,34 @@ files = [
     {file = "cymem-2.0.8.tar.gz", hash = "sha256:8fb09d222e21dcf1c7e907dc85cf74501d4cea6c4ed4ac6c9e016f98fb59cbbf"},
     {file = "cymem-2.0.8.tar.gz", hash = "sha256:8fb09d222e21dcf1c7e907dc85cf74501d4cea6c4ed4ac6c9e016f98fb59cbbf"},
 ]
 ]
 
 
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+description = "XML bomb protection for Python stdlib modules"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+files = [
+    {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
+    {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
+]
+
+[[package]]
+name = "dj-database-url"
+version = "2.1.0"
+description = "Use Database URLs in your Django Application."
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+    {file = "dj-database-url-2.1.0.tar.gz", hash = "sha256:f2042cefe1086e539c9da39fad5ad7f61173bf79665e69bf7e4de55fa88b135f"},
+    {file = "dj_database_url-2.1.0-py3-none-any.whl", hash = "sha256:04bc34b248d4c21aaa13e4ab419ae6575ef5f10f3df735ce7da97722caa356e0"},
+]
+
+[package.dependencies]
+Django = ">=3.2"
+typing-extensions = ">=3.10.0.0"
+
 [[package]]
 [[package]]
 name = "django"
 name = "django"
 version = "4.2.6"
 version = "4.2.6"
@@ -483,6 +557,28 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
 argon2 = ["argon2-cffi (>=19.1.0)"]
 argon2 = ["argon2-cffi (>=19.1.0)"]
 bcrypt = ["bcrypt"]
 bcrypt = ["bcrypt"]
 
 
+[[package]]
+name = "django-allauth"
+version = "0.57.0"
+description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "django-allauth-0.57.0.tar.gz", hash = "sha256:a095ef0db7de305d9175772c78e765ebd5fceb004ae61c1383d7fc1af0f7c5b1"},
+]
+
+[package.dependencies]
+Django = ">=3.2"
+pyjwt = {version = ">=1.7", extras = ["crypto"]}
+python3-openid = ">=3.0.8"
+requests = ">=2.0.0"
+requests-oauthlib = ">=0.3.0"
+
+[package.extras]
+mfa = ["qrcode (>=7.0.0)"]
+saml = ["python3-saml (>=1.15.0,<2.0.0)"]
+
 [[package]]
 [[package]]
 name = "django-ninja"
 name = "django-ninja"
 version = "0.22.2"
 version = "0.22.2"
@@ -1104,6 +1200,23 @@ files = [
     {file = "numpy-1.26.0.tar.gz", hash = "sha256:f93fc78fe8bf15afe2b8d6b6499f1c73953169fad1e9a8dd086cdff3190e7fdf"},
     {file = "numpy-1.26.0.tar.gz", hash = "sha256:f93fc78fe8bf15afe2b8d6b6499f1c73953169fad1e9a8dd086cdff3190e7fdf"},
 ]
 ]
 
 
+[[package]]
+name = "oauthlib"
+version = "3.2.2"
+description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+files = [
+    {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"},
+    {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"},
+]
+
+[package.extras]
+rsa = ["cryptography (>=3.0.0)"]
+signals = ["blinker (>=1.4.0)"]
+signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
+
 [[package]]
 [[package]]
 name = "packaging"
 name = "packaging"
 version = "23.2"
 version = "23.2"
@@ -1454,6 +1567,27 @@ typing-extensions = ">=3.7.4.3"
 dotenv = ["python-dotenv (>=0.10.4)"]
 dotenv = ["python-dotenv (>=0.10.4)"]
 email = ["email-validator (>=1.0.3)"]
 email = ["email-validator (>=1.0.3)"]
 
 
+[[package]]
+name = "pyjwt"
+version = "2.8.0"
+description = "JSON Web Token implementation in Python"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"},
+    {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"},
+]
+
+[package.dependencies]
+cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""}
+
+[package.extras]
+crypto = ["cryptography (>=3.4.0)"]
+dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"]
+docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"]
+tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
+
 [[package]]
 [[package]]
 name = "pyspark"
 name = "pyspark"
 version = "3.2.0"
 version = "3.2.0"
@@ -1530,6 +1664,25 @@ files = [
 [package.dependencies]
 [package.dependencies]
 six = ">=1.5"
 six = ">=1.5"
 
 
+[[package]]
+name = "python3-openid"
+version = "3.2.0"
+description = "OpenID support for modern servers and consumers."
+category = "main"
+optional = false
+python-versions = "*"
+files = [
+    {file = "python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf"},
+    {file = "python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b"},
+]
+
+[package.dependencies]
+defusedxml = "*"
+
+[package.extras]
+mysql = ["mysql-connector-python"]
+postgresql = ["psycopg2"]
+
 [[package]]
 [[package]]
 name = "pytz"
 name = "pytz"
 version = "2023.3.post1"
 version = "2023.3.post1"
@@ -1732,6 +1885,25 @@ redis = ["redis (>=3)"]
 security = ["itsdangerous (>=2.0)"]
 security = ["itsdangerous (>=2.0)"]
 yaml = ["pyyaml (>=5.4)"]
 yaml = ["pyyaml (>=5.4)"]
 
 
+[[package]]
+name = "requests-oauthlib"
+version = "1.3.1"
+description = "OAuthlib authentication support for Requests."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+    {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"},
+    {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"},
+]
+
+[package.dependencies]
+oauthlib = ">=3.0.0"
+requests = ">=2.0.0"
+
+[package.extras]
+rsa = ["oauthlib[signedtoken] (>=3.0.0)"]
+
 [[package]]
 [[package]]
 name = "s3transfer"
 name = "s3transfer"
 version = "0.7.0"
 version = "0.7.0"
@@ -2443,4 +2615,4 @@ indexer = ["ujson", "warcio", "idna", "beautifulsoup4", "lxml", "langdetect", "p
 [metadata]
 [metadata]
 lock-version = "2.0"
 lock-version = "2.0"
 python-versions = ">=3.10,<3.11"
 python-versions = ">=3.10,<3.11"
-content-hash = "fe5f238c57ec2d09acb6bdf8f46f33c7bbe499f68a7e34ab7bca1336e0ae881c"
+content-hash = "37c79d582b976c81d731ea9bac38911f8cf578ae72fe715e23ab7d1236712f81"

+ 2 - 0
pyproject.toml

@@ -37,6 +37,8 @@ django = "^4.2.4"
 django-ninja = "^0.22.2"
 django-ninja = "^0.22.2"
 requests-cache = "^1.1.0"
 requests-cache = "^1.1.0"
 redis = {extras = ["hiredis"], version = "^5.0.1"}
 redis = {extras = ["hiredis"], version = "^5.0.1"}
+django-allauth = "^0.57.0"
+dj-database-url = "^2.1.0"
 
 
 [tool.poetry.extras]
 [tool.poetry.extras]
 indexer = [
 indexer = [

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.