Merge pull request #117 from mwmbl/include-front-end

Include front end
This commit is contained in:
Daoud Clarke 2023-10-12 20:53:40 +01:00 committed by GitHub
commit 88c3437456
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 23577 additions and 9 deletions

View file

@ -1,3 +1,10 @@
FROM node:hydrogen-bullseye as front-end
COPY front-end /front-end
WORKDIR /front-end
RUN npm install && npm run build
FROM python:3.10.2-bullseye as base
ENV PYTHONFAULTHANDLER=1 \
@ -39,6 +46,9 @@ RUN apt-get update && apt-get install -y postgresql-client
# Copy only the required /venv directory from the builder image that contains mwmbl and its dependencies
COPY --from=builder /venv /venv
# Copy the front end build
COPY --from=front-end /front-end/dist /app/static
ADD nginx.conf.sigil /app
# Set up a volume where the data will live

View file

@ -0,0 +1,232 @@
body {
display: flex;
flex-direction: column;
overflow-y: scroll;
background-color: var(--light-color);
min-height: 100vh;
height: fit-content;
padding-top: 25px;
transition: padding 300ms ease;
}
@media (prefers-reduced-motion) {
body {
transition: none;
}
}
.branding {
display: flex;
align-items: center;
margin: 25px;
}
.brand-title {
text-align: center;
font-weight: var(--black-font-weight);
font-size: 1.5rem;
margin: 10px 15px 10px 10px;
}
.brand-icon {
height: 2.5rem;
}
.search-menu {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
align-items: center;
max-width: 800px;
margin: 0 auto;
width: 100%;
padding: 10px;
background-color: rgba(248, 248, 248, .9);
z-index: 10;
}
.search-menu.compact {
flex-direction: row;
}
.search-menu.compact .branding {
margin: 0 25px 0 0;
}
.search-menu.compact .brand-title {
font-size: 1.2rem;
}
.search-menu.compact .brand-icon {
height: 2rem;
}
mwmbl-search-bar {
width: 100%;
}
.search-bar {
position: relative;
}
.search-bar-input {
background-color: var(--gray-color);
border: none;
padding: 15px 15px 15px 50px;
border-radius: 10px;
outline: none;
font-size: var(--default-font-size);
width: 100%;
font-weight: var(--bold-font-weight);
box-shadow: 0 0 0 0 var(--primary-color);
transition:
box-shadow 200ms ease-in-out;
}
.search-bar-input::placeholder {
color: var(--dark-color);
opacity: .3;
}
.search-bar-input:focus {
box-shadow: 0 0 0 0.2rem var(--primary-color);
}
.search-bar i {
position: absolute;
top: 50%;
left: 15px;
transform: translateY(-50%);
color: var(--dark-color);
opacity: .3;
font-size: 1.5rem;
pointer-events: none;
}
mwmbl-results, footer {
display: block;
max-width: 800px;
width: 100%;
margin: 0 auto;
}
.results {
max-width: 100%;
list-style-type: none;
padding: 10px;
}
.result a {
display: block;
text-decoration: none;
color: var(--dark-color);
padding: 15px;
border-radius: 10px;
outline: 3px solid transparent;
outline-offset: 3px;
transition:
background-color 200ms ease-in-out,
outline 100ms ease-in-out;
}
.result:hover a, .result a:focus {
background-color: var(--gray-color);
}
.result a:focus {
outline: 3px solid var(--primary-color);
}
.result .link {
font-size: .9rem;
}
.result .title, .result .title>* {
color: var(--primary-color);
font-size: 1.1rem;
}
.result .extract {
opacity: .8;
font-size: .9rem;
}
.empty-result, .home {
text-align: center;
opacity: .5;
font-weight: var(--bold-font-weight);
}
footer {
position: sticky;
top: 100vh;
margin-bottom: 25px;
padding: 10px;
}
.footer-text {
text-align: center;
opacity: .5;
font-weight: var(--bold-font-weight);
margin-bottom: 10px;
}
.footer-list {
list-style-type: none;
padding: 0;
margin: 0;
display: flex;
justify-content: center;
gap: 10px;
}
.footer-link {
display: flex;
align-items: center;
text-decoration: none;
padding: 10px;
color: var(--dark-color);
border-radius: 10px;
background-color: var(--gray-color);
box-shadow: 0 0 0 0 var(--primary-color);
transition:
box-shadow 200ms ease-in-out;
}
.footer-link:hover {
box-shadow: 0 0 0 0.2rem var(--dark-color);
}
.footer-link i {
font-size: 1.2rem;
margin-right: 5px;
color: inherit;
}
.footer-link>span {
color: inherit;
font-size: var(--default-font-size);
font-weight: var(--bold-font-weight);
}
@media screen and (min-width:576px) {
.brand-title {
margin: 0 25px 0 15px;
}
}
.noscript {
display: flex;
flex-direction: column;
height: calc(100vh - 25px);
width: 100%;
justify-content: center;
align-items: center;
}
a {
font-weight: var(--bold-font-weight);
color: var(--primary-color);
text-decoration: underline;
}

View file

@ -0,0 +1,33 @@
/*
Josh's Custom CSS Reset
https://www.joshwcomeau.com/css/custom-css-reset/
*/
*, *::before, *::after {
box-sizing: border-box;
font-family: var(--regular-font);
color: var(--dark-color);
font-size: var(--default-font-size);
}
* {
margin: 0;
}
html, body {
height: 100%;
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
input, button, textarea, select {
font: inherit;
}
p, h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}
#root, #__next {
isolation: isolate;
}

View file

@ -0,0 +1,18 @@
:root {
/* This is the theme file, use it to define theme variables. */
/* Colors: */
--dark-color: #0A1931;
--primary-color: #185ADB;
--gray-color: #EEEEEE;
--light-color: #F8F8F8;
/* Fonts: */
--regular-font: 'Inter', sans-serif;
--default-font-size: 16px;
--default-font-weight: 400;
--bold-font-weight: 700;
--black-font-weight: 900;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,50 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("Inter-Regular.woff2?v=3.19") format("woff2"),
url("Inter-Regular.woff?v=3.19") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("Inter-Italic.woff2?v=3.19") format("woff2"),
url("Inter-Italic.woff?v=3.19") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("Inter-Bold.woff2?v=3.19") format("woff2"),
url("Inter-Bold.woff?v=3.19") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("Inter-BoldItalic.woff2?v=3.19") format("woff2"),
url("Inter-BoldItalic.woff?v=3.19") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("Inter-Black.woff2?v=3.19") format("woff2"),
url("Inter-Black.woff?v=3.19") format("woff");
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 900;
font-display: swap;
src: url("Inter-BlackItalic.woff2?v=3.19") format("woff2"),
url("Inter-BlackItalic.woff?v=3.19") format("woff");
}

Binary file not shown.

View file

@ -0,0 +1,106 @@
/*--------------------------------
Phosphor Web Font
-------------------------------- */
@font-face {
font-family: 'Phosphor';
src: url("Phosphor.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/*------------------------
base class definition
-------------------------*/
[class^="ph-"],
[class*=" ph-"] {
display: inline-flex;
}
[class^="ph-"]:before,
[class*=" ph-"]:before {
font: normal normal normal 1em/1 "Phosphor";
color: inherit;
flex-shrink: 0;
speak: none;
text-transform: none;
text-decoration: inherit;
text-align: center;
/* Better Font Rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/*------------------------
change icon size
-------------------------*/
/* relative units */
.ph-xxs {
font-size: 0.5em;
}
.ph-xs {
font-size: 0.75em;
}
.ph-sm {
font-size: 0.875em;
}
.ph-lg {
font-size: 1.3333em;
line-height: 0.75em;
vertical-align: -0.0667em;
}
.ph-xl {
font-size: 1.5em;
line-height: 0.6666em;
vertical-align: -0.075em;
}
.ph-1x {
font-size: 1em;
}
.ph-2x {
font-size: 2em;
}
.ph-3x {
font-size: 3em;
}
.ph-4x {
font-size: 4em;
}
.ph-5x {
font-size: 5em;
}
.ph-6x {
font-size: 6em;
}
.ph-7x {
font-size: 7em;
}
.ph-8x {
font-size: 8em;
}
.ph-9x {
font-size: 9em;
}
.ph-10x {
font-size: 10em;
}
.ph-fw {
text-align: center;
width: 1.25em;
}
/*------------------------
icons (to add an icon you want to use,
copy it from the unused.css file)
-------------------------*/
.ph-magnifying-glass-bold::before {
content: "\f8bf";
}
.ph-github-logo-bold::before {
content: "\f852";
}
.ph-info-bold::before {
content: "\f88f";
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 9375 9375" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<style>
path {
fill: #000;
}
@media ( prefers-color-scheme: dark ) {
path {
fill: #fff !important;
}
}
</style>
<path d="M6128.72,8251.56c495.65,0 919.697,-176.222 1272.13,-528.659c352.437,-352.438 528.659,-776.484 528.659,-1272.13l-0,-3358.75c-0,-94.644 -35.492,-176.841 -106.482,-246.581c-70.985,-69.739 -153.801,-104.612 -248.445,-104.612c-99.634,-0 -184.314,34.873 -254.054,104.612c-69.746,69.74 -104.612,151.937 -104.612,246.581l-0,3358.75c-0,301.373 -105.857,557.923 -317.571,769.63c-211.708,211.714 -468.251,317.571 -769.63,317.571c-298.89,0 -554.808,-105.857 -767.766,-317.571c-212.958,-211.707 -319.434,-468.257 -319.434,-769.63l-0,-3358.75c-0,-94.644 -34.873,-176.841 -104.613,-246.581c-69.739,-69.739 -154.426,-104.612 -254.054,-104.612c-94.649,-0 -176.841,34.873 -246.58,104.612c-69.74,69.74 -104.613,151.937 -104.613,246.581l0,3358.75c0,301.373 -106.476,557.923 -319.434,769.63c-212.959,211.714 -468.883,317.571 -767.766,317.571c-301.379,0 -557.923,-105.857 -769.636,-317.571c-211.708,-211.707 -317.565,-468.257 -317.565,-769.63l0,-3358.75c0,-94.644 -34.873,-176.841 -104.612,-246.581c-69.74,-69.739 -154.427,-104.612 -254.054,-104.612c-94.65,-0 -176.841,34.873 -246.581,104.612c-69.739,69.74 -104.612,151.937 -104.612,246.581l-0,3358.75c-0,326.283 80.327,627.662 240.976,904.131c160.656,276.469 378.593,495.031 653.817,655.686c275.224,160.649 575.984,240.977 902.267,240.977c291.416,0 563.525,-64.761 816.335,-194.277c252.81,-129.517 460.158,-307.608 622.058,-534.263c166.878,226.655 376.722,404.746 629.532,534.263c252.809,129.516 524.919,194.277 816.335,194.277Zm-0.96,-1617.39l-0.582,-0c-99.627,-0 -184.314,-34.873 -254.054,-104.612c-69.739,-69.74 -104.612,-151.938 -104.612,-246.581l-0,-3358.74c-0,-301.373 -105.857,-557.923 -317.565,-769.63c-210.698,-210.699 -465.799,-316.549 -765.32,-317.559c-299.521,1.01 -554.622,106.86 -765.314,317.559c-211.714,211.707 -317.571,468.257 -317.571,769.63l0,3358.75c0,94.644 -34.866,176.841 -104.606,246.581c-69.739,69.739 -154.426,104.612 -254.054,104.612l-8.638,0c-94.643,0 -176.841,-34.873 -246.58,-104.612c-69.74,-69.74 -104.613,-151.937 -104.613,-246.581l0,-3358.75c0,-301.373 -106.476,-557.923 -319.434,-769.63c-212.959,-211.714 -468.876,-317.571 -767.766,-317.571c-301.379,-0 -557.922,105.857 -769.63,317.571c-211.714,211.707 -317.571,468.257 -317.571,769.63l0,3358.75c0,94.644 -34.867,176.841 -104.612,246.581c-69.74,69.739 -154.42,104.612 -254.054,104.612c-94.644,0 -176.841,-34.873 -246.581,-104.612c-69.739,-69.74 -104.606,-151.937 -104.606,-246.581l0,-3358.75c0,-326.283 80.321,-627.662 240.977,-904.131c160.649,-276.469 378.586,-495.031 653.816,-655.686c275.224,-160.649 575.978,-240.977 902.261,-240.977c291.416,-0 563.526,64.761 816.335,194.277c252.81,129.517 460.164,307.608 622.058,534.263c166.878,-226.655 376.722,-404.746 629.532,-534.263c252.809,-129.516 524.919,-194.277 816.335,-194.277l8.638,-0c164.822,-0 323.472,20.718 475.941,62.154l5.239,1.431c41.114,11.263 81.609,24.024 121.497,38.284c72.687,25.87 143.907,56.675 213.652,92.408c250.636,128.408 456.592,304.549 617.866,528.412l4.328,5.665c166.872,-226.58 376.667,-404.598 629.396,-534.077c252.809,-129.516 524.925,-194.277 816.335,-194.277c495.657,-0 919.704,176.222 1272.14,528.659c352.437,352.438 528.653,776.484 528.653,1272.13l0,3358.75c0,94.644 -35.492,176.841 -106.476,246.581c-70.984,69.739 -153.801,104.612 -248.451,104.612c-99.627,0 -184.314,-34.873 -254.054,-104.612c-69.739,-69.74 -104.612,-151.937 -104.612,-246.581l-0,-3358.75c-0,-301.373 -105.851,-557.923 -317.565,-769.63c-211.713,-211.714 -468.257,-317.571 -769.636,-317.571c-298.883,-0 -554.807,105.857 -767.766,317.571c-212.952,211.707 -319.434,468.257 -319.434,769.63l-0,3358.75c-0,94.644 -34.867,176.841 -104.606,246.581c-69.746,69.739 -154.427,104.612 -254.055,104.612l-0.582,-0.006Z" style="stroke:#185ADB;stroke-width:4.17px;"/></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,4 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="25.909" y="49.7723" width="250.569" height="200.455" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M300 195C300 252.951 252.951 300 195 300H105C47.049 300 0 252.951 0 195V105C0 47.0489 47.049 0 105 0H195C252.951 0 300 47.0489 300 105V195ZM187.005 200.017H186.99C184.431 200.017 182.255 199.121 180.463 197.329C178.671 195.537 177.775 193.425 177.775 190.993V104.696C177.775 96.9523 175.055 90.3607 169.616 84.9212C164.202 79.5076 157.648 76.7879 149.952 76.762C142.256 76.7879 135.702 79.5076 130.288 84.9212C124.849 90.3607 122.129 96.9523 122.129 104.696V190.993C122.129 193.425 121.233 195.537 119.441 197.329C117.649 199.121 115.473 200.017 112.914 200.017H112.692C110.26 200.017 108.148 199.121 106.356 197.329C104.564 195.537 103.668 193.425 103.668 190.993V104.696C103.668 96.9523 100.933 90.3607 95.461 84.9212C89.9894 79.4815 83.414 76.7617 75.7345 76.7617C67.991 76.7617 61.3995 79.4815 55.96 84.9212C50.5203 90.3607 47.8005 96.9523 47.8005 104.696V190.993C47.8005 193.425 46.9047 195.537 45.1127 197.329C43.3208 199.121 41.1451 200.017 38.5851 200.017C36.1534 200.017 34.0415 199.121 32.2496 197.329C30.4578 195.537 29.5619 193.425 29.5619 190.993V104.696C29.5619 96.3123 31.6257 88.5688 35.7535 81.4654C39.8811 74.362 45.4806 68.7463 52.5523 64.6186C59.6237 60.4909 67.3511 58.427 75.7345 58.427C83.2219 58.427 90.2134 60.091 96.7089 63.4187C103.204 66.7464 108.532 71.3222 112.692 77.1457C116.979 71.3222 122.371 66.7464 128.867 63.4187C135.362 60.091 142.354 58.427 149.841 58.427H150.063C154.298 58.427 158.374 58.9594 162.292 60.024L162.426 60.0607C163.483 60.3501 164.523 60.678 165.548 61.0444C167.415 61.7091 169.245 62.5006 171.037 63.4187C177.477 66.7179 182.769 71.2436 186.912 76.9954L187.024 77.141C191.311 71.3193 196.701 66.7454 203.195 63.4187C209.691 60.091 216.682 58.427 224.169 58.427C236.905 58.427 247.8 62.9548 256.855 72.0101C265.91 81.0655 270.438 91.9607 270.438 104.696V190.993C270.438 193.425 269.526 195.537 267.702 197.329C265.879 199.121 263.751 200.017 261.319 200.017C258.759 200.017 256.583 199.121 254.791 197.329C252.999 195.537 252.103 193.425 252.103 190.993V104.696C252.103 96.9523 249.384 90.3607 243.944 84.9212C238.504 79.4815 231.913 76.7617 224.169 76.7617C216.49 76.7617 209.915 79.4815 204.443 84.9212C198.971 90.3607 196.236 96.9523 196.236 104.696V190.993C196.236 193.425 195.34 195.537 193.548 197.329C191.756 199.121 189.58 200.017 187.02 200.017L187.005 200.017ZM187.03 241.573C199.765 241.573 210.66 237.045 219.716 227.99C228.771 218.935 233.299 208.039 233.299 195.304V109.007C233.299 106.575 232.387 104.463 230.563 102.671C228.739 100.879 226.611 99.9832 224.179 99.9832C221.619 99.9832 219.444 100.879 217.652 102.671C215.86 104.463 214.964 106.575 214.964 109.007V195.304C214.964 203.048 212.244 209.639 206.804 215.079C201.365 220.518 194.773 223.238 187.03 223.238C179.35 223.238 172.775 220.518 167.303 215.079C161.832 209.639 159.096 203.048 159.096 195.304V109.007C159.096 106.575 158.2 104.463 156.408 102.671C154.616 100.879 152.44 99.9832 149.881 99.9832C147.449 99.9832 145.337 100.879 143.545 102.671C141.753 104.463 140.857 106.575 140.857 109.007V195.304C140.857 203.048 138.122 209.639 132.65 215.079C127.178 220.518 120.603 223.238 112.923 223.238C105.18 223.238 98.5884 220.518 93.1488 215.079C87.7093 209.639 84.9894 203.048 84.9894 195.304V109.007C84.9894 106.575 84.0934 104.463 82.3016 102.671C80.5097 100.879 78.3338 99.9832 75.7741 99.9832C73.3422 99.9832 71.2304 100.879 69.4386 102.671C67.6467 104.463 66.7507 106.575 66.7507 109.007V195.304C66.7507 203.688 68.8146 211.431 72.9422 218.535C77.07 225.638 82.6696 231.254 89.741 235.381C96.8125 239.509 104.54 241.573 112.923 241.573C120.411 241.573 127.402 239.909 133.898 236.581C140.393 233.254 145.721 228.678 149.881 222.854C154.168 228.678 159.56 233.254 166.056 236.581C172.551 239.909 179.543 241.573 187.03 241.573V241.573Z" fill="#185ADB"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

File diff suppressed because one or more lines are too long

29
front-end/config.js Normal file
View file

@ -0,0 +1,29 @@
/**
* This file is made for tweaking parameters on the front-end
* without having to dive in the source code.
*
* THIS IS NOT A PLACE TO PUT SENSIBLE DATA LIKE API KEYS.
* THIS FILE IS PUBLIC.
*/
export default {
componentPrefix: 'mwmbl',
publicApiURL: 'https://api.mwmbl.org/',
searchQueryParam: 'q',
footerLinks: [
{
name: 'Github',
icon: 'ph-github-logo-bold',
href: 'https://github.com/mwmbl/mwmbl'
},
{
name: 'Wiki',
icon: 'ph-info-bold',
href: 'https://github.com/mwmbl/mwmbl/wiki'
}
],
commands: {
'go: ': 'https://',
'search: google.com ': 'https://www.google.com/search?q=',
}
}

1268
front-end/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
front-end/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "front-end",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@vitejs/plugin-legacy": "^2.3.1",
"terser": "^5.16.1",
"vite": "^3.2.3"
},
"dependencies": {
"chart.js": "^4.4.0"
}
}

View file

@ -0,0 +1,26 @@
import define from '../utils/define.js';
const template = () => /*html*/`
<header class="search-menu">
<div class="branding">
<img class="brand-icon" src="/static/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>
<footer is="mwmbl-footer"></footer>
`;
export default define('app', class extends HTMLElement {
constructor() {
super();
this.__setup();
}
__setup() {
this.innerHTML = template();
}
});

View file

@ -0,0 +1,17 @@
import define from '../../utils/define.js';
const template = () => /*html*/`
<p>We could not find anything for your search...</p>
`;
export default define('empty-result', class extends HTMLLIElement {
constructor() {
super();
this.classList.add('empty-result');
this.__setup();
}
__setup() {
this.innerHTML = template();
}
}, { extends: 'li' });

View file

@ -0,0 +1,57 @@
import define from '../../utils/define.js';
import escapeString from '../../utils/escapeString.js';
import { globalBus } from '../../utils/events.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>
`;
export default define('result', class extends HTMLLIElement {
constructor() {
super();
this.classList.add('result');
this.__setup();
}
__setup() {
this.innerHTML = template({ data: {
url: this.dataset.url,
title: this.__handleBold(JSON.parse(this.dataset.title)),
extract: this.__handleBold(JSON.parse(this.dataset.extract))
}});
this.__events();
}
__events() {
this.addEventListener('keydown', (e) => {
if (this.firstElementChild === document.activeElement) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this?.nextElementSibling?.firstElementChild.focus();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (this.previousElementSibling)
this.previousElementSibling?.firstElementChild.focus();
else {
const focusSearchEvent = new CustomEvent('focus-search');
globalBus.dispatch(focusSearchEvent);
}
}
}
})
}
__handleBold(input) {
let text = '';
for (const part of input) {
if (part.is_bold) text += `<strong>${escapeString(part.value)}</strong>`;
else text += escapeString(part.value);
}
return text;
}
}, { extends: 'li' });

View file

@ -0,0 +1,36 @@
import define from '../../utils/define.js';
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*/`
<li class="footer-item">
<a href="${link.href}" class="footer-link" target="_blank">
<i class="${link.icon}"></i>
<span>${link.name}</span>
</a>
</li>
`).join('')}
</ul>
`;
export default define('footer', class extends HTMLElement {
constructor() {
super();
this.__setup();
}
__setup() {
this.innerHTML = template({
data: {
links: config.footerLinks
}
});
this.__events();
}
__events() {
}
}, { extends: 'footer' });

View file

@ -0,0 +1,22 @@
import define from '../../utils/define.js';
const template = () => /*html*/`
<h1>
Welcome to mwmbl, the free, open-source and non-profit search engine.
</h1>
<p>
You can start searching by using the search bar above!
</p>
`;
export default define('home', class extends HTMLLIElement {
constructor() {
super();
this.classList.add('home');
this.__setup();
}
__setup() {
this.innerHTML = template();
}
}, { extends: 'li' });

View file

@ -0,0 +1,75 @@
import define from '../../utils/define.js';
import { globalBus } from '../../utils/events.js';
// Components
import result from '../molecules/result.js';
import emptyResult from '../molecules/empty-result.js';
import home from './home.js';
import escapeString from '../../utils/escapeString.js';
const template = () => /*html*/`
<ul class='results'>
<li is='${home}'></li>
</ul>
`;
export default define('results', class extends HTMLElement {
constructor() {
super();
this.results = null;
this.__setup();
}
__setup() {
this.innerHTML = template();
this.results = this.querySelector('.results');
this.__events();
}
__events() {
globalBus.on('search', (e) => {
this.results.innerHTML = '';
let resultsHTML = '';
if (!e.detail.error) {
// If there is no details the input is empty
if (!e.detail.results) {
resultsHTML = /*html*/`
<li is='${home}'></li>
`;
}
// If the details array has results display them
else if (e.detail.results.length > 0) {
for(const resultData of e.detail.results) {
resultsHTML += /*html*/`
<li
is='${result}'
data-url='${escapeString(resultData.url)}'
data-title='${escapeString(JSON.stringify(resultData.title))}'
data-extract='${escapeString(JSON.stringify(resultData.extract))}'
></li>
`;
}
}
// If the details array is empty there is no result
else {
resultsHTML = /*html*/`
<li is='${emptyResult}'></li>
`;
}
}
else {
// If there is an error display an empty result
resultsHTML = /*html*/`
<li is='${emptyResult}'></li>
`;
}
// Bind HTML to the DOM
this.results.innerHTML = resultsHTML;
});
// Focus first element when coming from the search bar
globalBus.on('focus-result', () => {
this.results.firstElementChild.firstElementChild.focus();
})
}
});

View file

@ -0,0 +1,180 @@
import define from '../../utils/define.js';
import config from '../../../config.js';
import { globalBus } from '../../utils/events.js';
import debounce from '../../utils/debounce.js'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion)').matches;
const template = () => /*html*/`
<form class="search-bar">
<i class="ph-magnifying-glass-bold"></i>
<input
type='search'
class='search-bar-input'
placeholder='Search on mwmbl...'
title='Use "CTRL+K" or "/" to focus.'
autocomplete='off'
>
</form>
`;
export default define('search-bar', class extends HTMLElement {
constructor() {
super();
this.searchInput = null;
this.searchForm = null;
this.abortController = new AbortController();
this.__setup();
}
__setup() {
this.innerHTML = template();
this.searchInput = this.querySelector('input');
this.searchForm = this.querySelector('form');
this.__events();
}
__dispatchSearch({ results = null, error = null }) {
const searchEvent = new CustomEvent('search', {
detail: {
results,
error,
},
});
globalBus.dispatch(searchEvent)
}
/**
* Updates the overall layout of the page.
*
* `home` centers the search bar on the page.
* `compact` raises it to the top and makes room for displaying results.
*
* @param {'compact' | 'home'} mode
* @return {void}
*/
__setDisplayMode(mode) {
switch (mode) {
case 'compact': {
document.body.style.paddingTop = '25px';
document.querySelector('.search-menu').classList.add('compact');
break;
}
case 'home': {
document.body.style.paddingTop = '30vh';
document.querySelector('.search-menu').classList.remove('compact');
break;
}
}
}
async __executeSearch() {
this.abortController.abort();
this.abortController = new AbortController();
// Get response from API
const response = await fetch(`${config.publicApiURL}search?s=${encodeURIComponent(this.searchInput.value)}`, {
signal: this.abortController.signal
});
// Getting results from API
const search = await (response).json();
return search;
}
__handleSearch = async () => {
// Update page title
document.title = `MWMBL - ${this.searchInput.value || "Search"}`;
// Update query params
const queryParams = new URLSearchParams(document.location.search);
// Sets query param if search value is not empty
if (this.searchInput.value) queryParams.set(config.searchQueryParam, this.searchInput.value);
else queryParams.delete(config.searchQueryParam);
// New URL with query params
const newURL =
document.location.protocol
+ "//"
+ document.location.host
+ document.location.pathname
+ (this.searchInput.value ? '?' : '')
+ queryParams.toString();
// Replace history state
window.history.replaceState({ path: newURL }, '', newURL);
if (this.searchInput.value) {
this.__setDisplayMode('compact')
try {
const search = await this.__executeSearch()
// This is a guess at an explanation
// Check the searcInput.value before setting the results to prevent
// race condition where the user has cleared the search input after
// submitting an original search but before the search results have
// come back from the API
this.__dispatchSearch({ results: this.searchInput.value ? search : null });
}
catch(error) {
this.__dispatchSearch({ error })
}
}
else {
this.__setDisplayMode('home')
this.__dispatchSearch({ results: null });
}
}
__events() {
/**
* Always add the submit event, it makes things feel faster if
* someone does not prefer reduced motion and reflexively hits
* return once they've finished typing.
*/
this.searchForm.addEventListener('submit', (e) => {
e.preventDefault();
this.__handleSearch(e);
});
/**
* Only add the "real time" search behavior when the client does
* not prefer reduced motion; this prevents the page from changing
* while the user is still typing their query.
*/
if (!prefersReducedMotion) {
this.searchInput.addEventListener('input', debounce(this.__handleSearch, 500))
}
// Focus search bar when pressing `ctrl + k` or `/`
document.addEventListener('keydown', (e) => {
if ((e.key === 'k' && e.ctrlKey) || e.key === '/' || e.key === 'Escape') {
e.preventDefault();
this.searchInput.focus();
}
});
// Focus first result when pressing down arrow
this.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' && this.searchInput.value) {
e.preventDefault();
const focusResultEvent = new CustomEvent('focus-result');
globalBus.dispatch(focusResultEvent);
}
});
globalBus.on('focus-search', (e) => {
this.searchInput.focus();
});
}
connectedCallback() {
// Focus search input when component is connected
this.searchInput.focus();
const searchQuery = new URLSearchParams(document.location.search).get(config.searchQueryParam);
this.searchInput.value = searchQuery;
/**
* Trigger search handling to coordinate the value pulled from the query string
* across the rest of the UI and to actually retrieve the results if the search
* value is now non-empty.
*/
this.__handleSearch();
}
});

63
front-end/src/index.html Normal file
View file

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Metas -->
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Page title -->
<title>MWMBL - Search</title>
<meta name="description" content="The free, open-source and non-profit search engine.">
<!-- Favicons -->
<link rel="icon" href="/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'">
<noscript>
<link rel="stylesheet" href="/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">
<!-- Phosphor Icons (https://github.com/phosphor-icons/phosphor-home) -->
<link rel="preload" href="/fonts/phosphor/icons.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="/fonts/phosphor/icons.css">
</noscript>
<!-- Custom Element Polyfill for Safari -->
<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">
</head>
<body>
<mwmbl-app></mwmbl-app>
<noscript>
<main class="noscript">
<img class="brand-icon" src="/static/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">
Github
</a>
.
</p>
</main>
</noscript>
<!-- Javasript entrypoint -->
<script src="./index.js" type="module"></script>
</body>
</html>

22
front-end/src/index.js Normal file
View file

@ -0,0 +1,22 @@
/**
* This file is mainly used as an entry point
* to import components or define globals.
*
* Please do not pollute this file if you can make
* util or component files instead.
*/
// Waiting for top-level await to be better supported.
(async () => {
// Check if a suggestion redirect is needed.
const { redirectToSuggestions } = await import("./utils/suggestions.js");
const redirected = redirectToSuggestions();
if (!redirected) {
// Load components only after redirects are checked.
import('./components/app.js');
import("./components/organisms/search-bar.js");
import("./components/organisms/results.js");
import("./components/organisms/footer.js");
}
})();

View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Mwmbl Stats</title>
<!-- Favicons -->
<link rel="icon" href="/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'">
<noscript>
<link rel="stylesheet" href="/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="stats.css">
</head>
<body>
<section>
<div class="info">
<h1>Mwmbl Stats</h1>
<p>
Mwmbl is a <a href="https://matrix.to/#/#mwmbl:matrix.org">community</a> devoted to building a
<a href="https://en.wikipedia.org/wiki/Free_and_open-source_software">free</a> search engine. You can try it
out <a href="/">here</a> or help us improve the index by
<a href="https://en.wikipedia.org/wiki/Web_crawler">crawling</a> the web with our
<a href="https://addons.mozilla.org/en-GB/firefox/addon/mwmbl-web-crawler/">Firefox extension</a>
or <a href="https://github.com/mwmbl/crawler-script">command line script</a>.
</p>
</div>
</section>
<section>
<div class="info">
<h1>Number of users crawling today: <span id="num-users"></span></h1>
<div class="wrap">
<canvas id="users-by-day"></canvas>
</div>
</div>
<div class="info">
<h1>Number of URLs crawled today: <span id="num-urls"></span></h1>
<div class="wrap">
<canvas id="urls-by-day"></canvas>
</div>
</div>
<div class="info">
<div class="wrap">
<canvas id="urls-by-hour"></canvas>
</div>
</div>
</section>
<section>
<div class="info tall">
<div class="wrap tall">
<canvas id="urls-by-user"></canvas>
</div>
</div>
<div class="info tall">
<div class="wrap tall">
<canvas id="urls-by-domain"></canvas>
</div>
</div>
</section>
<script src="./stats.js" type="module"></script>
</body>
</html>

View file

@ -0,0 +1,33 @@
body {
background: #eeeeee;
}
section {
display: flex;
flex-wrap: wrap;
}
.info {
flex: 1 500px;
margin: 10px;
padding: 50px;
background: #ffffff;
border-radius: 50px;
}
.wrap {
height: 512px;
}
#users-by-day-info {
width: 100%;
}
#url-info {
height: 3000px;
}
.tall {
height: 3000px;
}

View file

@ -0,0 +1,113 @@
import {Chart} from "chart.js/auto";
(async () => {
Chart.defaults.font.size = 16;
function createChart(elementId, labels, label) {
const canvas = document.getElementById(elementId);
return new Chart(canvas, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: label,
borderWidth: 1
}]
},
options: {
scales: {
y: {
beginAtZero: true
}
},
maintainAspectRatio: false
}
});
}
const urlsCrawledDailyChart = createChart('urls-by-day', null, "URLs crawled by day");
const urlsCrawledHourlyChart = createChart('urls-by-hour', [...Array(24).keys()], "URLs crawled today by hour")
const usersCrawledDailyChart = createChart('users-by-day', null, "Number of users crawling by day")
const urlsByUserCanvas = document.getElementById('urls-by-user');
const byUserChart = new Chart(urlsByUserCanvas, {
type: 'bar',
data: {
datasets: [{
label: "Top users",
borderWidth: 1
// barThickness: 15
}]
},
options: {
scales: {
x: {
beginAtZero: true
}
},
indexAxis: 'y',
maintainAspectRatio: false
}
});
const urlsByDomainCanvas = document.getElementById('urls-by-domain');
const byDomainChart = new Chart(urlsByDomainCanvas, {
type: 'bar',
data: {
datasets: [{
label: "Top domains",
borderWidth: 1
}]
},
options: {
scales: {
x: {
beginAtZero: true
}
},
indexAxis: 'y',
maintainAspectRatio: false
}
});
function updateStats() {
fetch("https://api.mwmbl.org/crawler/stats").then(result => {
result.json().then(stats => {
console.log("Stats", stats);
const urlCountSpan = document.getElementById("num-urls");
urlCountSpan.innerText = stats.urls_crawled_today;
const numUsers = Object.values(stats.users_crawled_daily)[Object.keys(stats.users_crawled_daily).length - 1];
const userCountSpan = document.getElementById("num-users");
userCountSpan.innerText = numUsers;
usersCrawledDailyChart.data.labels = Object.keys(stats.users_crawled_daily);
usersCrawledDailyChart.data.datasets[0].data = Object.values(stats.users_crawled_daily);
usersCrawledDailyChart.update();
urlsCrawledHourlyChart.data.datasets[0].data = stats.urls_crawled_hourly;
urlsCrawledHourlyChart.update();
urlsCrawledDailyChart.data.labels = Object.keys(stats.urls_crawled_daily);
urlsCrawledDailyChart.data.datasets[0].data = Object.values(stats.urls_crawled_daily);
urlsCrawledDailyChart.update();
byUserChart.data.labels = Object.keys(stats.top_users);
byUserChart.data.datasets[0].data = Object.values(stats.top_users);
byUserChart.update();
byDomainChart.data.labels = Object.keys(stats.top_domains);
byDomainChart.data.datasets[0].data = Object.values(stats.top_domains);
byDomainChart.update();
})
});
}
updateStats();
setInterval(() => {
updateStats();
}, 5000);
})();

View file

@ -0,0 +1,13 @@
/**
* A debounce function to reduce input spam
* @param {*} callback Function that will be called
* @param {*} timeout Minimum amount of time between calls
* @returns The debounced function
*/
export default (callback, timeout = 100) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => { callback.apply(this, args); }, timeout);
};
}

View file

@ -0,0 +1,15 @@
import config from '../../config.js';
/** Define a web component, this is a wrapper
* around the `customElements.define` native function.
* @function define
* @param {string} name Name of the component (will be prefixed by the config `componentPrefix`)
* @param {CustomElementConstructor} constructor
* @param {ElementDefinitionOptions} [options]
* @returns {string} Returns the element name ready for the DOM (.e.g `<search-bar></search-bar>`)
*/
export default (name, constructor, options) => {
const componentName = `${config.componentPrefix}-${name}`;
if (!customElements.get(componentName)) customElements.define(componentName, constructor, options);
return componentName;
}

View file

@ -0,0 +1,10 @@
/**
* Escapes string with HTML Characters Codes.
* @param {string} input String to escape
* @returns {string}
*/
export default (input) => {
return String(input).replace(/[^\w. ]/gi, (character) => {
return `&#${character.charCodeAt(0)};`;
});
}

View file

@ -0,0 +1,30 @@
/**
* A class destined to be used as an event bus.
*
* It is simply a trick using a div element
* to carry events.
*/
class Bus {
constructor() {
this.element = document.createElement('div');
}
on(eventName, callback) {
this.element.addEventListener(eventName, callback);
}
dispatch(event) {
this.element.dispatchEvent(event);
}
}
/**
* A global event bus that can be used to
* dispatch events in between components
* */
const globalBus = new Bus();
export {
Bus,
globalBus,
}

View file

@ -0,0 +1,24 @@
/**
* Handle redirect requests from the suggestion back-end.
*/
import config from "../../config.js";
const redirectToSuggestions = () => {
const search = decodeURIComponent(document.location.search).replace(/\+/g, ' ').substr(3);
console.log("Search", search);
for (const [command, urlTemplate] of Object.entries(config.commands)) {
console.log("Command", command);
if (search.startsWith(command)) {
const newUrl = urlTemplate + search.substr(command.length);
window.location.replace(newUrl);
return true;
}
}
return false;
}
export {
redirectToSuggestions
};

22
front-end/vite.config.js Normal file
View file

@ -0,0 +1,22 @@
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'),
},
},
},
plugins: [
legacy({
targets: ['defaults', 'not IE 11'],
}),
]
}

View file

@ -6,7 +6,7 @@ import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mwmbl.settings_dev')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:

View file

@ -22,9 +22,6 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# 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'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
ALLOWED_HOSTS = ["api.mwmbl.org"]
@ -117,6 +114,8 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/'
STATICFILES_DIRS = [str(Path(__file__).parent.parent / "front-end" / "dist")]
print("Static files", STATICFILES_DIRS)
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field

View file

@ -1,5 +1,8 @@
from mwmbl.settings_common import *
DEBUG = True
DATA_PATH = "./devdata"
RUN_BACKGROUND_PROCESSES = False
NUM_PAGES = 2560

View file

@ -1,5 +1,7 @@
from mwmbl.settings_common import *
DEBUG = False
DATA_PATH = "/app/storage"
RUN_BACKGROUND_PROCESSES = True
NUM_PAGES = 10240000

View file

@ -144,6 +144,11 @@ server {
{{ if $.CLIENT_MAX_BODY_SIZE }}client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }};{{ end }}
include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf;
## Static file hosting
location /static/ {
alias /var/lib/dokku/data/storage/mwmbl/;
}
error_page 400 401 402 403 405 406 407 408 409 410 411 412 413 414 415 416 417 418 420 422 423 424 426 428 429 431 444 449 450 451 /400-error.html;
location /400-error.html {
root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors;
@ -167,11 +172,6 @@ server {
root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors;
internal;
}
## Static file hosting
location /static/ {
alias /var/lib/dokku/data/storage/mwmbl/;
}
}
{{ else if eq $scheme "grpc"}}
{{ if eq $.GRPC_SUPPORTED "true"}}{{ if eq $.HTTP2_SUPPORTED "true"}}