Add original curation front-end

This commit is contained in:
Daoud Clarke 2023-10-25 19:17:02 +01:00
parent bb9e6aa4bd
commit 4d823497a6
21 changed files with 795 additions and 106 deletions

View file

@ -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

View file

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

View file

@ -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",

View file

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

View file

@ -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>
`;

View 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);
}
}
});

View 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' });

View 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">&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' });

View 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' });

View file

@ -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 {

View 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' });

View file

@ -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' });

View file

@ -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);
}
});

View 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' });

View file

@ -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();
}
});

View 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);
}
}
});

View file

@ -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>
.

View file

@ -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");
}
})();

View file

@ -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>

View file

@ -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({