Add original curation front-end
This commit is contained in:
parent
bb9e6aa4bd
commit
4d823497a6
21 changed files with 795 additions and 106 deletions
|
@ -117,6 +117,10 @@ mwmbl-results, footer {
|
|||
padding: 10px;
|
||||
}
|
||||
|
||||
.result {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.result a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
|
@ -229,4 +233,94 @@ a {
|
|||
font-weight: var(--bold-font-weight);
|
||||
color: var(--primary-color);
|
||||
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;
|
||||
}
|
File diff suppressed because one or more lines are too long
|
@ -9,6 +9,7 @@
|
|||
export default {
|
||||
componentPrefix: 'mwmbl',
|
||||
publicApiURL: 'https://api.mwmbl.org/',
|
||||
// publicApiURL: 'http://localhost:5000/',
|
||||
searchQueryParam: 'q',
|
||||
footerLinks: [
|
||||
{
|
||||
|
|
82
front-end/package-lock.json
generated
82
front-end/package-lock.json
generated
|
@ -5,9 +5,6 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"name": "front-end",
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-legacy": "^2.3.1",
|
||||
"terser": "^5.16.1",
|
||||
|
@ -113,11 +110,6 @@
|
|||
"@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": {
|
||||
"version": "2.3.1",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
|
@ -598,16 +579,10 @@
|
|||
}
|
||||
},
|
||||
"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,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
|
@ -628,9 +603,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"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,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -640,14 +615,10 @@
|
|||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"nanoid": "^3.3.4",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
|
@ -765,9 +736,9 @@
|
|||
}
|
||||
},
|
||||
"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,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.15.9",
|
||||
|
@ -884,11 +855,6 @@
|
|||
"@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": {
|
||||
"version": "2.3.1",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
|
@ -1145,9 +1103,9 @@
|
|||
}
|
||||
},
|
||||
"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
|
||||
},
|
||||
"path-parse": {
|
||||
|
@ -1163,12 +1121,12 @@
|
|||
"dev": true
|
||||
},
|
||||
"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,
|
||||
"requires": {
|
||||
"nanoid": "^3.3.6",
|
||||
"nanoid": "^3.3.4",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
}
|
||||
|
@ -1252,9 +1210,9 @@
|
|||
}
|
||||
},
|
||||
"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,
|
||||
"requires": {
|
||||
"esbuild": "^0.15.9",
|
||||
|
|
|
@ -11,8 +11,5 @@
|
|||
"@vitejs/plugin-legacy": "^2.3.1",
|
||||
"terser": "^5.16.1",
|
||||
"vite": "^3.2.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
import define from '../utils/define.js';
|
||||
import addResult from "./molecules/add-result.js";
|
||||
import save from "./organisms/save.js";
|
||||
|
||||
const template = () => /*html*/`
|
||||
<header class="search-menu">
|
||||
<ul>
|
||||
<li is="${save}"></li>
|
||||
</ul>
|
||||
<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>
|
||||
</div>
|
||||
<mwmbl-search-bar></mwmbl-search-bar>
|
||||
</header>
|
||||
<main>
|
||||
<mwmbl-results></mwmbl-results>
|
||||
</main>
|
||||
<main>
|
||||
<mwmbl-results></mwmbl-results>
|
||||
</main>
|
||||
<div is="${addResult}"></div>
|
||||
<footer is="mwmbl-footer"></footer>
|
||||
`;
|
||||
|
||||
|
|
69
front-end/src/components/login.js
Normal file
69
front-end/src/components/login.js
Normal file
|
@ -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
front-end/src/components/molecules/add-button.js
Normal file
24
front-end/src/components/molecules/add-button.js
Normal file
|
@ -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
front-end/src/components/molecules/add-result.js
Normal file
69
front-end/src/components/molecules/add-result.js
Normal file
|
@ -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">×</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
front-end/src/components/molecules/delete-button.js
Normal file
35
front-end/src/components/molecules/delete-button.js
Normal file
|
@ -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' });
|
|
@ -1,13 +1,25 @@
|
|||
import define from '../../utils/define.js';
|
||||
import escapeString from '../../utils/escapeString.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*/`
|
||||
<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 {
|
||||
|
|
53
front-end/src/components/molecules/validate-button.js
Normal file
53
front-end/src/components/molecules/validate-button.js
Normal file
|
@ -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' });
|
|
@ -4,14 +4,14 @@ import config from '../../../config.js';
|
|||
const template = ({ data }) => /*html*/`
|
||||
<p class="footer-text">Find more on</p>
|
||||
<ul class="footer-list">
|
||||
${data.links.map(link => /*html*/`
|
||||
${ data.links.map(link => /*html*/`
|
||||
<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>
|
||||
<span>${link.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
`).join('')}
|
||||
`).join('') }
|
||||
</ul>
|
||||
`;
|
||||
|
||||
|
@ -22,7 +22,7 @@ export default define('footer', class extends HTMLElement {
|
|||
}
|
||||
|
||||
__setup() {
|
||||
this.innerHTML = template({
|
||||
this.innerHTML = template({
|
||||
data: {
|
||||
links: config.footerLinks
|
||||
}
|
||||
|
@ -31,6 +31,6 @@ export default define('footer', class extends HTMLElement {
|
|||
}
|
||||
|
||||
__events() {
|
||||
|
||||
|
||||
}
|
||||
}, { extends: 'footer' });
|
|
@ -1,5 +1,5 @@
|
|||
import define from '../../utils/define.js';
|
||||
import { globalBus } from '../../utils/events.js';
|
||||
import {globalBus} from '../../utils/events.js';
|
||||
|
||||
// Components
|
||||
import result from '../molecules/result.js';
|
||||
|
@ -17,6 +17,8 @@ export default define('results', class extends HTMLElement {
|
|||
constructor() {
|
||||
super();
|
||||
this.results = null;
|
||||
this.oldIndex = null;
|
||||
this.curating = false;
|
||||
this.__setup();
|
||||
}
|
||||
|
||||
|
@ -31,7 +33,7 @@ export default define('results', class extends HTMLElement {
|
|||
this.results.innerHTML = '';
|
||||
let resultsHTML = '';
|
||||
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) {
|
||||
resultsHTML = /*html*/`
|
||||
<li is='${home}'></li>
|
||||
|
@ -42,7 +44,7 @@ export default define('results', class extends HTMLElement {
|
|||
for(const resultData of e.detail.results) {
|
||||
resultsHTML += /*html*/`
|
||||
<li
|
||||
is='${result}'
|
||||
is='${result}'
|
||||
data-url='${escapeString(resultData.url)}'
|
||||
data-title='${escapeString(JSON.stringify(resultData.title))}'
|
||||
data-extract='${escapeString(JSON.stringify(resultData.extract))}'
|
||||
|
@ -65,11 +67,167 @@ export default define('results', class extends HTMLElement {
|
|||
}
|
||||
// Bind HTML to the DOM
|
||||
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
|
||||
globalBus.on('focus-result', () => {
|
||||
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
front-end/src/components/organisms/save.js
Normal file
122
front-end/src/components/organisms/save.js
Normal file
|
@ -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' });
|
||||
|
|
@ -144,8 +144,12 @@ export default define('search-bar', class extends HTMLElement {
|
|||
|
||||
// Focus search bar when pressing `ctrl + k` or `/`
|
||||
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();
|
||||
|
||||
// Remove the modal if it's visible
|
||||
document.querySelector('.modal').style.display = 'none';
|
||||
|
||||
this.searchInput.focus();
|
||||
}
|
||||
});
|
||||
|
|
84
front-end/src/components/register.js
Normal file
84
front-end/src/components/register.js
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -35,21 +35,28 @@
|
|||
<script src="https://unpkg.com/@ungap/custom-elements" type="module"></script>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<body>
|
||||
<mwmbl-login></mwmbl-login>
|
||||
<mwmbl-register></mwmbl-register>
|
||||
<mwmbl-app></mwmbl-app>
|
||||
<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>
|
||||
Welcome to mwmbl, the free, open-source and non-profit search engine.
|
||||
</h1>
|
||||
<p>This website requires you to support/enable scripts.</p>
|
||||
<p>
|
||||
More information on
|
||||
<a href="https://github.com/mwmbl/mwmbl" target="_blank">
|
||||
<a href="https://github.com/mwmbl/mwmbl" target="__blank">
|
||||
Github
|
||||
</a>
|
||||
.
|
||||
|
|
|
@ -15,8 +15,11 @@
|
|||
if (!redirected) {
|
||||
// Load components only after redirects are checked.
|
||||
import('./components/app.js');
|
||||
import('./components/login.js');
|
||||
import('./components/register.js');
|
||||
import("./components/organisms/search-bar.js");
|
||||
import("./components/organisms/results.js");
|
||||
import("./components/organisms/footer.js");
|
||||
import("./components/organisms/save.js");
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -5,18 +5,18 @@
|
|||
<title>Mwmbl Stats</title>
|
||||
|
||||
<!-- 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 -->
|
||||
<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>
|
||||
<link rel="stylesheet" href="/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="/static/fonts/inter/inter.css">
|
||||
</noscript>
|
||||
|
||||
<!-- 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">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -1,18 +1,11 @@
|
|||
import legacy from '@vitejs/plugin-legacy'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default {
|
||||
root: './src',
|
||||
base: '/static',
|
||||
publicDir: '../assets',
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'src/index.html'),
|
||||
stats: resolve(__dirname, 'src/stats/index.html'),
|
||||
},
|
||||
},
|
||||
outDir: '../dist'
|
||||
},
|
||||
plugins: [
|
||||
legacy({
|
||||
|
|
Loading…
Add table
Reference in a new issue