feature (refactoring): start of the frontend refactoring

This commit is contained in:
Mickael Kerjean 2023-07-18 18:17:09 +10:00
parent c2059c839d
commit 42f5434dfe
35 changed files with 1337 additions and 0 deletions

16
public/README.org Normal file
View file

@ -0,0 +1,16 @@
WIP of the complete frontend rewrite in vanilla JS
Admin section:
- [-] pages:
- [ ] backend
- [ ] settings
- [ ] logs
- [ ] about
- [ ] setup
- [X] home
- [ ] login
- [ ] side bar
- [ ] form
End user section:
TODO

187
public/assets/css/reset.css Normal file
View file

@ -0,0 +1,187 @@
/* latin-ext */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-weight: 400;
src: local('Source Code Pro'), local('SourceCodePro-Regular'), url(/assets/fonts/SourceCodePro-Regular-400-latin-ext.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-weight: 400;
src: local('Source Code Pro'), local('SourceCodePro-Regular'), url(/assets/fonts/SourceCodePro-Regular-400-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-weight: 600;
src: local('Source Code Pro Semibold'), local('SourceCodePro-Semibold'), url(/assets/fonts/SourceCodePro-Semibold-600-latin-ext.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Code Pro';
font-style: normal;
font-weight: 600;
src: local('Source Code Pro Semibold'), local('SourceCodePro-Semibold'), url(/assets/fonts/SourceCodePro-Semibold-600-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root {
--bg-color: #fafafa;
--color: #57595A;
--emphasis: #466372;
--primary: #9AD1ED;
--emphasis-primary: #c5e2f1;
--emphasis-secondary: #466372;
--light: #909090;
--super-light: #f9fafc;
--error: #f26d6d;
--success: #63d9b1;
--dark: #313538;
}
body.dark-mode {
--bg-color: #242424;
}
html {
font-family: "Roboto", sans-serif;
-webkit-text-size-adjust:100%;
}
body {
overflow: hidden;
background: var(--bg-color);
color: var(--color);
}
body, html, #app {
height: 100%;
margin: 0;
}
body.dark-mode{ background: var(--bg-color); }
body.dark-mode .component_modal > div,
body.dark-mode .component_page_admin{ --bg-color: #f2f3f5; }
.center{ text-align: center; }
a { text-decoration: none; }
select {
-webkit-appearance: none;
-moz-appearance: none;
}
select:-moz-focusring {
color: transparent;
outline: none;
border: none;
}
select::-ms-expand {
display: none;
}
button::-moz-focus-inner {
border: 0;
}
input, textarea, select {
transition: border 0.2s;
outline: none;
}
.no-select {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
button:focus,
a:focus,
a:active,
button::-moz-focus-inner,
input[type="reset"]::-moz-focus-inner,
input[type="button"]::-moz-focus-inner,
input[type="submit"]::-moz-focus-inner,
select::-moz-focus-inner,
input[type="file"]>input[type="button"]::-moz-focus-inner {
outline: none !important;
}
select:-moz-focusring {
color: transparent;
text-shadow: 0 0 0 #000;
}
.connect-form input:hover,
.connect-form textarea:hover,
.connect-form input:focus,
.connect-form textarea:focus {
border-color: rgb(154, 209, 237)!important;
}
.drag-drop {
z-index: 2;
}
.drag-drop.dragging>div {
background: rgba(0, 0, 0, 0.1);
}
/* CONNECTION FORM */
.login-form button.active {
box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.20);
}
::-webkit-scrollbar {
height: 4px;
width: 4px
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, .1)
}
::-webkit-scrollbar-thumb {
-webkit-border-radius: 2px;
-moz-border-radius: 2px;
-ms-border-radius: 2px;
-o-border-radius: 2px;
border-radius: 2px;
background: rgba(0, 0, 0, .2);
}
.scroll-y {
overflow-y: scroll;
scrollbar-3dlight-color: #7d7e94;
scrollbar-arrow-color: #c1c1d1;
scrollbar-darkshadow-color: #2d2c4d;
scrollbar-face-color: rgba(0, 0, 0, .1);
scrollbar-highlight-color: #7d7e94;
scrollbar-shadow-color: #2d2c4d;
scrollbar-track-color: rgba(0, 0, 0, .1);
}
.pointer {
cursor: pointer;
}
.hidden{
position:absolute;
left:-10000px;
top:auto;
width:1px;
height:1px;
overflow:hidden;
}

View file

@ -0,0 +1,39 @@
class Loader extends HTMLElement {
constructor() {
super();
this.innerHTML = this.render();
}
render() {
return `<style>${CSS}</style>
<div className="component_loader">
<svg width="120px" height="120px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<rect x="0" y="0" width="100" height="100" fill="none"></rect>
<circle cx="50" cy="50" r="40" stroke="rgba(100%,100%,100%,0.679)" fill="none" strokeWidth="10" strokeLinecap="round"></circle>
<circle cx="50" cy="50" r="40" stroke="#6f6f6f" fill="none" strokeWidth="6" strokeLinecap="round">
<animate attributeName="stroke-dashoffset" dur="2s" repeatCount="indefinite" from="0" to="502"></animate>
<animate attributeName="stroke-dasharray" dur="2s" repeatCount="indefinite" values="150.6 100.4;1 250;150.6 100.4"></animate>
</circle>
</svg>
</div>`;
}
}
customElements.define("data-loader", Loader);
const CSS = `
.component_loader{
text-align: center;
margin: 50px auto 0 auto;
}
.loader-appear{
opacity: 0;
}
.loader-appear.loader-appear-active{
transition: opacity 0.2s ease-out;
transition-delay: 0.5s;
opacity: 1;
}
`

View file

@ -0,0 +1,13 @@
import { createElement } from "../common/skeleton/index.js";
export function prompt(label, okFn, errFn) {
const $node = createElement(`
<dialog open>
<p>Greetings, one and all!</p>
<form method="dialog">
<button>OK</button>
</form>
</dialog>`);
document.body.appendChild($node);
okFn("OK")
}

39
public/index.html Normal file
View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/">
<script type="module" src="./pages/boot/nyancat.js"></script>
<link rel="stylesheet" href="./assets/css/reset.css">
<title></title>
</head>
<body>
<div role="main" id="app">
<data-nyancat></data-nyancat>
</div>
<script type="module" src="./components/loader.js"></script>
<script type="module">
import main from "./lib/skeleton/index.js";
const routes = {
"/admin/backend": "pages/adminpage/ctrl_backend.js",
"/admin/settings": "pages/adminpage/ctrl_settings.js",
"/admin/logs": "pages/adminpage/ctrl_logger.js",
"/admin/about": "pages/adminpage/ctrl_about.js",
"/admin": "pages/adminpage/index.js",
"/": "pages/home/index.js",
};
main(document.getElementById("app"), routes, {
loader: `<data-loader></data-loader>`
});
</script>
<noscript>
<div>
<h2>Error: Javascript is off</h2>
<p>You need to enable Javascript to run this application</p>
</div>
</noscript>
</body>
</html>

View file

@ -0,0 +1,110 @@
import rxjs from "../rxjs/index.js";
export async function requestAnimation() {
return new Promise((done) => requestAnimationFrame(done));
}
export async function enterAnimation($node, timeout) {
$node.classList.remove("leave", "leave-active", "enter", "enter-active");
await requestAnimation();
$node.classList.add("enter");
await requestAnimation();
$node.classList.add("enter-active")
await rxjs.timer(timeout).toPromise();
$node.classList.remove("enter", "enter-active");
}
export async function leaveAnimation($node, timeout) {
$node.classList.remove("leave", "leave-active", "enter", "enter-active");
await requestAnimation();
$node.classList.add("leave");
await requestAnimation();
$node.classList.add("leave-active")
await rxjs.timer(timeout).toPromise();
}
export function slideXIn(size) {
return function (querySelector, t){
return `
${querySelector}.enter {
opacity: 0;
transform: translateX(${size}px);
}
${querySelector}.enter.enter-active {
opacity: 1;
transform: translateX(0);
transition: all ${t}ms ease;
}`;
}
}
export function slideYIn(size) {
return function (querySelector, t){
return `
${querySelector}.enter {
opacity: 0;
transform: translateY(${size}px);
}
${querySelector}.enter.enter-active {
opacity: 1;
transform: translateY(0);
transition: all ${t}ms ease;
}`;
}
}
export function slideXOut(size) {
return function (querySelector, t){
return `
${querySelector}.leave {
opacity: 1;
transform: translateX(0);
}
${querySelector}.leave.leave-active {
opacity: 0;
transform: translateX(${size}px);
transition: all ${t}ms ease;
}`;
}
}
export function slideYOut(size) {
return function (querySelector, t){
return `
${querySelector}.leave {
opacity: 1;
transform: translateY(0);
}
${querySelector}.leave.leave-active {
opacity: 0;
transform: translateY(${size}px);
transition: all ${t}ms ease;
}`;
}
}
export function opacityIn() {
return function (querySelector, t){
return `
${querySelector}.enter {
opacity: 0;
}
${querySelector}.enter.enter-active {
opacity: 1;
transition: opacity ${t}ms ease;
}`;
}
}
export function opacityOut() {
return function (querySelector, t){
return `
${querySelector}.leave {
opacity: 1;
}
${querySelector}.leave.leave-active {
opacity: 0;
transition: opacity ${t}ms ease;
}`;
}
}

View file

@ -0,0 +1,40 @@
import { onDestroy, createElement } from "../skeleton/index.js";
import rxjs from "../rxjs/index.js";
import {
requestAnimation, enterAnimation, leaveAnimation,
slideXIn, opacityOut,
} from "./animation.js";
export {
slideXIn, slideXOut,
slideYIn, slideYOut,
opacityIn, opacityOut
} from "./animation.js";
export function animate($node, opts = {}) {
const { timeoutEnter = 200, timeoutLeave = 100 } = opts;
return rxjs.of({ $node: $node, timeoutEnter, timeoutLeave });
}
export function CSSTransition(opts = {}) {
const { enter = slideXIn(3), leave = opacityOut() } = opts;
return rxjs.pipe(
rxjs.tap(({$node, timeoutEnter, timeoutLeave}) => {
if ($node.classList.value === "") { // if node has no class, assign a random one
$node.classList.add((Math.random() + 1).toString(36).substring(2));
}
const className = (" " + $node.classList.value).split(" ").join(".");
let css = "";
if (timeoutEnter && typeof enter === "function") css += enter(className, timeoutEnter);
if (timeoutLeave && typeof leave === "function") css += leave(className, timeoutLeave);
if (css) $node.appendChild(createElement(`<style>${css}</style>`));
}),
rxjs.tap(({$node, timeoutEnter, timeoutLeave}) => {
if (timeoutEnter && enter) enterAnimation($node, timeoutEnter);
if (timeoutLeave && leave) onDestroy(async () => {
await leaveAnimation($node, timeoutLeave);
});
}),
);
}

31
public/lib/rxjs/dom.js Normal file
View file

@ -0,0 +1,31 @@
import rxjs from "./index.js";
export function textContent($node, selector = "") {
if (selector) $node = $node.querySelector(selector);
if (!$node) throw new Error("dom not found for '" + selector + "'");
return rxjs.tap((val) => $node.textContent = val);
}
export function htmlContent($node, selector = "") {
if (selector) $node = $node.querySelector(selector);
if (!$node) throw new Error("dom not found for '" + selector + "'");
return rxjs.tap((val) => $node.innerHTML = val);
}
export function setAttribute($node, selector = "", attr = "") {
if (selector) $node = $node.querySelector(selector);
if (!$node) throw new Error("dom not found for '" + selector + "'");
return rxjs.tap((val) => ($node.getAttribute(attr) != val) && $node.setAttribute(attr, val));
}
export function removeAttribute($node, selector = "", attr = "") {
if (selector) $node = $node.querySelector(selector);
if (!$node) throw new Error("dom not found for '" + selector + "'");
return rxjs.tap(() => $node.removeAttribute(attr));
}
export function getAttribute($node, selector = "", attr = "") {
if (selector) $node = $node.querySelector(selector);
if (!$node) throw new Error("dom not found for '" + selector + "'");
return rxjs.map(() => $node.getAttribute(attr))
}

13
public/lib/rxjs/index.js Normal file
View file

@ -0,0 +1,13 @@
import { onDestroy } from "../skeleton/index.js";
// https://github.com/ReactiveX/rxjs/issues/4416#issuecomment-620847759
const rxjsModule = await import("./vendor/rxjs.min.js");
const ajaxModule = await import("./vendor/rxjs-ajax.min.js")
export default rxjsModule;
export { textContent, htmlContent, setAttribute, getAttribute, removeAttribute } from "./dom.js";
export const ajax = ajaxModule.ajax;
export function withEffect(obs) {
const tmp = obs.subscribe(() => {}, (err) => console.error("withEffect", err));
onDestroy(() => tmp.unsubscribe());
}

View file

@ -0,0 +1,2 @@
/* rxjs@7.8.1 */
export{ca as AjaxError,cc as AjaxResponse,cb as AjaxTimeoutError,c9 as ajax}from"./rxjs-shared.min.js";

View file

@ -0,0 +1,2 @@
/* rxjs@7.8.1 */
export{an as audit,ao as auditTime,ap as buffer,aq as bufferCount,ar as bufferTime,as as bufferToggle,at as bufferWhen,au as catchError,av as combineAll,c2 as combineLatest,aw as combineLatestAll,ax as combineLatestWith,c3 as concat,ay as concatAll,az as concatMap,aA as concatMapTo,aB as concatWith,aC as connect,aD as count,aE as debounce,aF as debounceTime,aG as defaultIfEmpty,aH as delay,aI as delayWhen,aJ as dematerialize,aK as distinct,aL as distinctUntilChanged,aM as distinctUntilKeyChanged,aN as elementAt,aO as endWith,aP as every,aQ as exhaust,aR as exhaustAll,aS as exhaustMap,aT as expand,aU as filter,aV as finalize,aW as find,aX as findIndex,aY as first,b6 as flatMap,aZ as groupBy,a_ as ignoreElements,a$ as isEmpty,b0 as last,b1 as map,b2 as mapTo,b3 as materialize,b4 as max,c4 as merge,b5 as mergeAll,b7 as mergeMap,b8 as mergeMapTo,b9 as mergeScan,ba as mergeWith,bb as min,bc as multicast,bd as observeOn,c5 as onErrorResumeNext,bf as pairwise,c6 as partition,bg as pluck,bh as publish,bi as publishBehavior,bj as publishLast,bk as publishReplay,c7 as race,bl as raceWith,bm as reduce,br as refCount,bn as repeat,bo as repeatWhen,bp as retry,bq as retryWhen,bs as sample,bt as sampleTime,bu as scan,bv as sequenceEqual,bw as share,bx as shareReplay,by as single,bz as skip,bA as skipLast,bB as skipUntil,bC as skipWhile,bD as startWith,bE as subscribeOn,bF as switchAll,bG as switchMap,bH as switchMapTo,bI as switchScan,bJ as take,bK as takeLast,bL as takeUntil,bM as takeWhile,bN as tap,bO as throttle,bP as throttleTime,bQ as throwIfEmpty,bR as timeInterval,bS as timeout,bT as timeoutWith,bU as timestamp,bV as toArray,bW as window,bX as windowCount,bY as windowTime,bZ as windowToggle,b_ as windowWhen,b$ as withLatestFrom,c8 as zip,c0 as zipAll,c1 as zipWith}from"./rxjs-shared.min.js";

File diff suppressed because one or more lines are too long

2
public/lib/rxjs/vendor/rxjs.min.js vendored Normal file
View file

@ -0,0 +1,2 @@
/* rxjs@7.8.1 */
export{J as ArgumentOutOfRangeError,A as AsyncSubject,B as BehaviorSubject,h as ConnectableObservable,ak as EMPTY,K as EmptyError,al as NEVER,L as NotFoundError,N as Notification,z as NotificationKind,M as ObjectUnsubscribedError,O as Observable,R as ReplaySubject,x as Scheduler,P as SequenceError,b as Subject,y as Subscriber,S as Subscription,T as TimeoutError,U as UnsubscriptionError,c as VirtualAction,V as VirtualTimeScheduler,v as animationFrame,w as animationFrameScheduler,k as animationFrames,l as asap,m as asapScheduler,q as async,r as asyncScheduler,an as audit,ao as auditTime,Q as bindCallback,W as bindNodeCallback,ap as buffer,aq as bufferCount,ar as bufferTime,as as bufferToggle,at as bufferWhen,au as catchError,av as combineAll,X as combineLatest,aw as combineLatestAll,ax as combineLatestWith,Y as concat,ay as concatAll,az as concatMap,aA as concatMapTo,aB as concatWith,am as config,aC as connect,Z as connectable,aD as count,aE as debounce,aF as debounceTime,aG as defaultIfEmpty,_ as defer,aH as delay,aI as delayWhen,aJ as dematerialize,aK as distinct,aL as distinctUntilChanged,aM as distinctUntilKeyChanged,aN as elementAt,$ as empty,aO as endWith,aP as every,aQ as exhaust,aR as exhaustAll,aS as exhaustMap,aT as expand,aU as filter,aV as finalize,aW as find,aX as findIndex,aY as first,I as firstValueFrom,b6 as flatMap,a0 as forkJoin,a1 as from,a2 as fromEvent,a3 as fromEventPattern,a4 as generate,aZ as groupBy,F as identity,a_ as ignoreElements,a5 as iif,a6 as interval,a$ as isEmpty,G as isObservable,b0 as last,H as lastValueFrom,b1 as map,b2 as mapTo,b3 as materialize,b4 as max,a7 as merge,b5 as mergeAll,b7 as mergeMap,b8 as mergeMapTo,b9 as mergeScan,ba as mergeWith,bb as min,bc as multicast,a8 as never,E as noop,j as observable,bd as observeOn,a9 as of,aa as onErrorResumeNext,be as onErrorResumeNextWith,ab as pairs,bf as pairwise,ac as partition,D as pipe,bg as pluck,bh as publish,bi as publishBehavior,bj as publishLast,bk as publishReplay,s as queue,u as queueScheduler,ad as race,bl as raceWith,ae as range,bm as reduce,br as refCount,bn as repeat,bo as repeatWhen,bp as retry,bq as retryWhen,bs as sample,bt as sampleTime,bu as scan,aj as scheduled,bv as sequenceEqual,bw as share,bx as shareReplay,by as single,bz as skip,bA as skipLast,bB as skipUntil,bC as skipWhile,bD as startWith,bE as subscribeOn,bF as switchAll,bG as switchMap,bH as switchMapTo,bI as switchScan,bJ as take,bK as takeLast,bL as takeUntil,bM as takeWhile,bN as tap,bO as throttle,bP as throttleTime,af as throwError,bQ as throwIfEmpty,bR as timeInterval,bS as timeout,bT as timeoutWith,ag as timer,bU as timestamp,bV as toArray,ah as using,bW as window,bX as windowCount,bY as windowTime,bZ as windowToggle,b_ as windowWhen,b$ as withLatestFrom,ai as zip,c0 as zipAll,c1 as zipWith}from"./rxjs-shared.min.js";

View file

@ -0,0 +1,40 @@
import { init as initRouter, currentRoute } from "./router.js";
import { init as initDOM } from "./lifecycle.js";
export { navigate } from "./router.js";
export { onDestroy } from "./lifecycle.js";
export default async function($root, routes, opts = {}) {
const { spinner = "loading ...", spinnerTime = 200, defaultRoute = "/", onload = () => {} } = opts;
initDOM($root);
initRouter($root);
window.addEventListener("pagechange", async () => {
await $root.cleanup();
const route = currentRoute(routes, defaultRoute);
let ctrl;
if (typeof route === "function") {
ctrl = route;
} else if (typeof route === "string") {
const spinnerID = (typeof spinner === "string") && setTimeout(() => $root.innerHTML = spinner, spinnerTime);
const module = await import("../../" + route);
clearTimeout(spinnerID);
if (typeof module.default !== "function") return $root.replaceChildren(createElement(`<div><h1>Error</h1><p>missing default export on ${route}`));
ctrl = module.default;
}
if (typeof ctrl !== "function") return $root.replaceChildren(createElement(`<div><h1>Error</h1><p>Unknown route for ${route}`));
ctrl((view) => {
if (typeof view === "string") $root.replaceChildren(createElement(view));
else if (view instanceof window.Element) $root.replaceChildren(view);
else $root.replaceChildren(createElement(`<div><h1>Error</h1><p>Unknown view type: ${typeof view}</p></div>`));
onload();
});
});
}
export function createElement(str) {
const $n = window.document.createElement("div");
$n.innerHTML = str;
return $n.firstElementChild;
}

View file

@ -0,0 +1,131 @@
import main, { createElement, onDestroy } from "./index.js";
describe("router with inline controller", () => {
it("can render a string", async () => {
// given
const $app = window.document.createElement("div");
const routes = {
"/": (render) => render(`<h1 id="test">main</h1>`),
};
// when
main($app, routes);
window.dispatchEvent(new window.Event("pagechange"));
await nextTick();
expect($app.querySelector("#test").textContent).toBe("main");
});
it("can render a dom node", async () => {
// given
const $app = window.document.createElement("div");
const $node = createElement(`<h1 id="test">main</h1>`);
const routes = {
"/": (render) => render($node),
};
// when
main($app, routes);
window.dispatchEvent(new window.Event("pagechange"));
// then
await nextTick()
expect($node instanceof window.Element).toBe(true)
expect($app.querySelector("#test").textContent).toBe("main")
});
it("errors when given a non valid route", async () => {
// given
const $app = window.document.createElement("div");
const $node = createElement(`<h1 id="test">main</h1>`);
const routes = {
"/": null,
};
// when
main($app, routes);
window.dispatchEvent(new window.Event("pagechange"));
// then
await nextTick()
expect($node instanceof window.Element).toBe(true)
expect($app.querySelector("h1").textContent).toBe("Error")
});
it("errors when given a non valid render", async () => {
// given
const $app = window.document.createElement("div");
const $node = createElement(`<h1 id="test">main</h1>`);
const routes = {
"/": (render) => render({ json: "object", is: "not_ok" }),
};
// when
main($app, routes);
window.dispatchEvent(new window.Event("pagechange"));
// then
await nextTick()
expect($node instanceof window.Element).toBe(true)
expect($app.querySelector("h1").textContent).toBe("Error")
});
});
describe("router with es6 module as a controller", () => {
it("render the default import", async () => {
// given
const $app = window.document.createElement("div");
const routes = {
"/": "./common/skeleton/test/ctrl/ok.js",
};
// when
main($app, routes);
window.dispatchEvent(new window.Event("pagechange"));
// then
await nextTick();
expect($app.querySelector("h1").textContent.trim()).toBe("hello world");
});
it("error when missing the default render", async () => {
// given
const $app = window.document.createElement("div");
const routes = {
"/": "./common/skeleton/test/ctrl/nok.js",
};
// when
main($app, routes);
window.dispatchEvent(new window.Event("pagechange"));
// then
await nextTick();
expect($app.querySelector("h1").textContent.trim()).toBe("Error");
});
});
describe("navigation", () => {
it("using a link with data-link attribute for SPA", async () => {
// given
const $app = window.document.createElement("div");
const routes = {
"/": "./common/skeleton/test/ctrl/link.js",
"/something": (render) => render(`<h1>OK</h1>`),
};
const destroy = jest.fn();
// when
main($app, routes);
window.dispatchEvent(new window.Event("pagechange"));
await nextTick();
expect(window.location.pathname).toBe("/");
onDestroy(destroy);
$app.querySelector("#spa-link").click();
await nextTick();
// then
expect(destroy).toHaveBeenCalled();
expect(window.location.pathname).toBe("/something");
});
});

View file

@ -0,0 +1,13 @@
let _cleanup = [];
export async function init($root) {
$root.cleanup = () => {
const fns = _cleanup.map((fn) => fn($root))
_cleanup = [];
return Promise.all(fns);
};
}
export async function onDestroy(fn) {
_cleanup.push(fn);
}

View file

@ -0,0 +1,30 @@
const triggerPageChange = () => window.dispatchEvent(new window.Event("pagechange"));
export function init($root) {
window.addEventListener("DOMContentLoaded", triggerPageChange);
window.addEventListener("popstate", triggerPageChange);
$root.addEventListener("click", (e) => {
const href = _getHref(e.target, $root);
return !href ? null : e.preventDefault() || navigate(href);
});
}
export function navigate(href) {
window.history.pushState("", "", href);
triggerPageChange();
}
export function currentRoute(r, defaultRoute) {
for (const prefix in r) {
if (window.location .pathname.startsWith(prefix)) {
return r[prefix];
}
}
return r[defaultRoute] || null;
}
function _getHref ($node, $root) {
if ($node.matches("[data-link]")) return $node.getAttribute("href");
if (!$node.parentElement || $node.isSameNode($root)) return null;
return _getHref($node.parentElement, $root);
}

View file

@ -0,0 +1,89 @@
import { createElement } from "./index.js";
import { currentRoute, init } from "./router.js";
import * as routerModule from "./router.js";
describe("router", () => {
it("logic to get the current route", () => {
// given
let res;
const routes = {
"/foo": "route /foo",
"/bar": "route /bar",
}
window.location.pathname = "/";
// when, then
expect(window.location.pathname).toBe("/");
expect(currentRoute({ "/": "route /", ...routes})).toBe("route /");
expect(currentRoute(routes, "/foo")).toBe("route /foo");
expect(currentRoute(routes)).toBe(null);
});
it("trigger a page change when DOMContentLoaded", () => {
// given
const fn = jest.fn();
init(createElement(`<div></div>`));
window.addEventListener("pagechange", fn);
// when
window.dispatchEvent(new window.Event("DOMContentLoaded"));
// then
expect(fn).toBeCalled();
});
it("trigger a page change when history back", () => {
// given
const fn = jest.fn();
init(createElement(`<div></div>`));
window.addEventListener("pagechange", fn);
// when
window.dispatchEvent(new window.Event("popstate"));
// then
expect(fn).toBeCalled();
});
it("trigger a page change when clicking on a link with [data-link] attribute", () => {
// given
const fn = jest.fn();
const $link = createElement(`<a href="/something" data-link></a>`)
init($link);
window.addEventListener("pagechange", fn);
// when
$link.click();
// then
expect(fn).toBeCalled();
});
it("trigger a page change when clicking on a link with [data-link] attribute - recursive", () => {
// given
const fn = jest.fn();
const $link = createElement(`<a href="/something" data-link><div id="click-here">test</div></a>`)
init($link);
window.addEventListener("pagechange", fn);
// when
$link.querySelector("#click-here").click();
// then
expect(fn).toBeCalled();
});
it("does nothing when clicking not on a link", () => {
// given
const fn = jest.fn();
const $app = createElement(`<div>
<div id="click-here">test</div>
<a href="/something" data-link></a>
</div>`);
init($app);
window.addEventListener("pagechange", fn);
// when
$app.querySelector("#click-here").click();
// then
expect(fn).not.toBeCalled();
});
});

1
public/model/global.js Normal file
View file

@ -0,0 +1 @@
export const API_SERVER = "http://127.0.0.1:8000/proxy";

1
public/model/index.js Normal file
View file

@ -0,0 +1 @@
export { API_SERVER } from "./global.js";

26
public/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"type": "module",
"name": "Filestash",
"version": "1.0.0",
"description": "Frontend for Filestash",
"main": "index.js",
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest"
},
"author": "Mickael Kerjean",
"license": "AGPL",
"devDependencies": {
"jest": "^29.5.0",
"jsdom": "^22.1.0"
},
"jest": {
"verbose": true,
"setupFiles": [
"<rootDir>/common/skeleton/test/jest.setup.js"
],
"coveragePathIgnorePatterns": [
"test",
"example"
]
}
}

View file

@ -0,0 +1,21 @@
import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { withEffect } from "../../lib/rxjs/index.js";
import Release from "./model_release.js";
import AdminOnly from "./decorator_admin_only.js";
import WithAdminMenu from "./decorator_sidemenu.js";
export default AdminOnly(WithAdminMenu(function(render) {
render(createElement(`
<div class="component_page_about">
<Loader />
</div>
`));
withEffect(Release.get().pipe(rxjs.tap(({ html }) => {
render(createElement(`
<div class="component_page_about">
${html}
</div>
`));
})));
}));

View file

@ -0,0 +1,18 @@
import { createElement } from "../../lib/skeleton/index.js";
import { withEffect } from "../../lib/rxjs/index.js";
import { animate, CSSTransition } from "../../lib/animate/index.js";
import AdminOnly from "./decorator_admin_only.js";
import WithAdminMenu from "./decorator_sidemenu.js";
export default AdminOnly(WithAdminMenu(function(render) {
const $page = createElement(`
<div className="component_settingspage sticky">
<form>
FORM BUILDER BACKEND
</form>
</div>
`);
render($page);
withEffect(animate($page).pipe(CSSTransition()));
}));

View file

@ -0,0 +1,33 @@
import { createElement } from "../../lib/skeleton/index.js";
import { withEffect } from "../../lib/rxjs/index.js";
import { animate, CSSTransition } from "../../lib/animate/index.js";
import AdminOnly from "./decorator_admin_only.js";
import WithAdminMenu from "./decorator_sidemenu.js";
function Page(render) {
const $page = createElement(`
<div class="component_logpage sticky">
<h2>Logging</h2>
<div class="component_logger"></div>
<h2>Activity Report</h2>
<div class="component_reporter"></div>
<div>
`);
render($page);
withEffect(animate($page).pipe(CSSTransition()));
renderComponentLog($page.querySelector(".component_logger"));
renderComponentAuditor($page.querySelector(".component_reporter"));
}
export default AdminOnly(WithAdminMenu(Page));
function renderComponentLog($component) {
// console.log($component);
}
function renderComponentAuditor($component) {
// console.log($component);
}

View file

@ -0,0 +1,18 @@
import { createElement } from "../../lib/skeleton/index.js";
import { withEffect } from "../../lib/rxjs/index.js";
import { animate, CSSTransition } from "../../lib/animate/index.js";
import AdminOnly from "./decorator_admin_only.js";
import WithAdminMenu from "./decorator_sidemenu.js";
export default AdminOnly(WithAdminMenu(function(render) {
const $page = createElement(`
<div className="component_settingspage sticky">
<form>
FORM BUILDER SETTINGS
</form>
</div>
`);
render($page);
withEffect(animate($page).pipe(CSSTransition()));
}));

View file

@ -0,0 +1,37 @@
import { createElement, onDestroy } from "../../lib/skeleton/index.js";
import { withEffect } from "../../lib/rxjs/index.js";
import rxjs from "../../lib/rxjs/index.js";
import AdminSessionManager from "./model_admin_session.js";
export default function AdminOnly(ctrl) {
return async (render) => {
const loader$ = rxjs.timer(1000).subscribe(() => render(`<div>loading</div>`));
onDestroy(() => loader$.unsubscribe());
const handlerUserIsAdminPassthrough = () => ctrl(render);
const handlerUserIsNOTAdmin = () => {
const $form = createElement(`
<div>
login page
<form>
<input type="text" data-bind="password"/><button>submit</button>
</form>
</div>
`);
render($form);
withEffect(rxjs.fromEvent($form.querySelector("form"), "submit").pipe(
rxjs.tap((e) => e.preventDefault()),
rxjs.map(() => ({ password: $form.querySelector(`[data-bind="password"]`).value })),
AdminSessionManager.startSession(),
rxjs.tap((success) => console.log("FAIL LOGIN make things move", success)),
));
};
withEffect(AdminSessionManager.state().pipe(
rxjs.tap(() => loader$.unsubscribe()),
rxjs.map(({ isAdmin }) => isAdmin ? handlerUserIsAdminPassthrough : handlerUserIsNOTAdmin),
rxjs.tap((fn) => fn()),
));
};
}

View file

@ -0,0 +1,30 @@
import { createElement } from "../../../lib/skeleton/index.js";
import rxjs, { withEffect, textContent } from "../../../lib/rxjs/index.js";
import Release from "./model_release.js";
export default function(ctrl) {
return (render) => {
const $page = createElement(`
<div class="component_page_admin">
<div class="component_menu_sidebar no-select">
<h2>Admin console</h2>
<ul>
<li><a href="/admin/backend" data-link>Backend</a></li>
<li><a href="/admin/settings" data-link>Settings</a></li>
<li><a href="/admin/logs" data-link>Logs</a></li>
<li><a class="version" href="/admin/about" data-link data-bind="version"></a></li>
</ul>
</div>
<div class="page_container scroll-y" data-bind="admin"></div>
</div>
`);
render($page);
ctrl(($node) => $page.querySelector(`[data-bind="admin"]`).appendChild($node));
withEffect(Release.get().pipe(
rxjs.map(({ version }) => version),
textContent($page, `[data-bind="version"]`),
));
};
}

View file

@ -0,0 +1,5 @@
import { navigate } from "../../lib/skeleton/index.js";
export default function() {
navigate("/admin/backend");
}

View file

@ -0,0 +1,31 @@
import { createElement, navigate } from "../../lib/skeleton/index.js";
import rxjs, { withEffect, textContent } from "../../lib/rxjs/index.js";
import { animate, CSSTransition } from "../../lib/animate/index.js";
export default function(render) {
const $page = createElement(`
<div>
<h1>Admin</h1>
<p>
Settings <br/>
<form>
<input id="ftp_username" name="username" type="text" />
<input id="ftp_password" name="password" type="text" />
</form>
<a href="./" data-link>go home</a>
</p>
</div>`);
render($page);
withEffect(animate($page).pipe(CSSTransition()));
const formValues = rxjs.combineLatest(
rxjs.
fromEvent($page.querySelector("#ftp_username"), "input").
pipe(rxjs.map((e) => ({name: e.target.id, value: e.target.value})), rxjs.startWith(null)),
rxjs.
fromEvent($page.querySelector("#ftp_password"), "input").
pipe(rxjs.map((e) => ({name: e.target.id, value: e.target.value}), rxjs.startWith(null)))
).subscribe((e) => { // pipe onto reducer function that build our form object
console.log("OK", e)
});
};

View file

@ -0,0 +1,29 @@
import rxjs, { ajax } from "../../lib/rxjs/index.js";
import { API_SERVER } from "../../model/index.js";
window.rxjs = rxjs;
class AdminSessionManager {
constructor() {
this.session = new rxjs.ReplaySubject(1);
ajax(API_SERVER + "/admin/api/session").subscribe(
() => this.session.next({ isAdmin: false }),
() => this.session.next({ isAdmin: false }),
);
}
state() {
return this.session.asObservable().pipe(rxjs.delay(100));
}
startSession() {
return rxjs.pipe(
rxjs.delay(1000),
rxjs.tap(() => this.session.next({ isAdmin: true })),
rxjs.mapTo(false),
);
}
}
export default new AdminSessionManager();

View file

@ -0,0 +1,24 @@
import rxjs, { ajax } from "../../lib/rxjs/index.js";
import { API_SERVER } from "../../model/index.js";
const release$ = ajax({
url: API_SERVER + "/about",
responseType: "text",
}).pipe(rxjs.shareReplay(1));
class ReleaseImpl {
get() {
return release$.pipe(
rxjs.map((xhr) => {
const a = document.createElement("html")
a.innerHTML = xhr.response;
return {
html: a.querySelector("table").outerHTML,
version: xhr.responseHeaders["x-powered-by"].trim().replace(/^Filestash\/([v\.0-9]*).*$/, "$1"),
};
}),
);
}
}
export default new ReleaseImpl();

View file

@ -0,0 +1,164 @@
class NyanCat extends HTMLElement {
constructor() {
super();
this.innerHTML = this.render();
}
render() {
return `<style>${CSS()}</style>
<div id="n-lder" class="loading">
<div id="cat">
<div id="hide-behind" class="background-color"></div>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="32" height="21" preserveAspectRatio="xMinYMin meet" viewBox="0 0 33 21">
<g id="nyan_all">
<g id="nyan_feet">
<g>
<path d="m 4,20 0,-3 1,0 0,-1 4,0 0,3 -1,0 0,1 z" style="fill:#000000;"></path>
<path d="m 5,19 0,-2 3,0 0,1 -1,0 0,1 z" style="fill:#999999;"></path>
<path d="m 10,20 0,-2 4,0 0,1 -1,0 0,1 z" style="fill:#000000;"></path>
<path d="m 11,18 2,0 0,1 -2,0 z" style="fill:#999999;"></path>
</g>
<g transform="matrix(-1,0,0,1,32,0)">
<path d="m 10,20 0,-2 4,0 0,1 -1,0 0,1 z" style="fill:#000000;"></path>
<path d="m 11,18 2,0 0,1 -2,0 z" style="fill:#999999;"></path>
<path d="m 4,20 0,-3 1,0 0,-1 4,0 0,3 -1,0 0,1 z" style="fill:#000000;"></path>
<path d="m 5,19 0,-2 3,0 0,1 -1,0 0,1 z" style="fill:#999999;"></path>
</g>
</g>
<g id="nyan_tail">
<path d="M 0,10 0,7 4,7 4,8 5,8 5,9 6,9 6,14 5,14 5,13 3,13 3,12 2,12 2,11 1,11 1,10 z" style="fill:#000000;" />
<path d="m 1,9 0,-1 2,0 0,1 1,0 0,1 1,0 0,1 1,0 0,1 -2,0 0,-1 -1,0 0,-1 -1,0 0,-1 z" style="fill:#999999;" />
</g>
<g id="nyan_body">
<path d="m 7,1 19,0 0,16 -19,0 z" style="fill:#ffcc99;" />
<path d="m 8,14 0,-10 1,0 0,-1 1,0 0,-1 13,0 0,1 1,0 0,1 1,0 0,10 -1,0 0,1 -1,0 0,1 -13,0 0,-1 -1,0 0,-1 z" style="fill:#ff99ff;" />
<path d="m 22,5 1,0 0,1 -1,0 z m -4,-2 1,0 0,1 -1,0 z m -3,0 1,0 0,1 -1,0 z m -1,4 1,0 0,1 -1,0 z m 1,3 1,0 0,1 -1,0 z m -2,3 1,0 0,1 -1,0 z m -2,-4 1,0 0,1 -1,0 z m -2,2 1,0 0,1 -1,0 z m 1,3 1,0 0,1 -1,0 z m 0,-10 1,0 0,1 -1,0 z" style="fill:#ff3399;" />
<path d="m 8,17 17,0 0,1 -17,0 z m 0,-17 17,0 0,1 -17,0 z m 18,16 0,-14 1,0 0,14 z m -20,0 0,-14 1,0 0,14 z m 1,0 1,0 0,1 -1,0 z m 0,-15 1,0 0,1 -1,0 z m 18,0 1,0 0,1 -1,0 z m 0,15 1,0 0,1 -1,0 z" style="fill:#000000;" />
</g>
<g id="nyan_head">
<path d="m 17,15 0,-5 1,0 0,-4 2,0 0,1 1,0 0,1 1,0 0,1 4,0 0,-1 1,0 0,-1 1,0 0,-1 2,0 0,4 1,0 0,5 -1,0 0,1 -1,0 0,1 -10,0 0,-1 -1,0 0,-1 z" style="fill:#999999;fill-opacity:1;stroke:none" />
<path d="m 29,16 1,0 0,1 -1,0 z m 1,-1 1,0 0,1 -1,0 z m 1,-5 1,0 0,5 -1,0 z m -1,-4 1,0 0,4 -1,0 z m -2,-1 2,0 0,1 -2,0 z m -6,3 4,0 0,1 -4,0 z m -4,-3 2,0 0,1 -2,0 z m -1,1 1,0 0,4 -1,0 z m -1,4 1,0 0,5 -1,0 z m 11,-4 1,0 0,1 -1,0 z m -1,1 1,0 0,1 -1,0 z m -5,0 1,0 0,1 -1,0 z m -1,-1 1,0 0,1 -1,0 z m -1,11 10,0 0,1 -10,0 z m -1,-1 1,0 0,1 -1,0 z m -1,-1 1,0 0,1 -1,0 z" style="fill:#000000;fill-opacity:1;stroke:none" />
<path d="m 18,13 2,0 0,2 -2,0 z" style="fill:#ff9999;fill-opacity:1;stroke:none" />
<path d="m 29,13 2,0 0,2 -2,0 z" style="fill:#ff9999;fill-opacity:1;stroke:none" />
<path d="m 21,16 0,-2 1,0 0,1 2,0 0,-1 1,0 0,1 2,0 0,-1 1,0 0,2 z" style="fill:#000000;fill-opacity:1;stroke:none" />
<path d="m 25,12 1,0 0,1 -1,0 z" style="fill:#000000;fill-opacity:1;stroke:none" />
<g>
<path d="m 27,13 0,-1 1,0 0,-1 1,0 0,2 z" style="fill:#000000;fill-opacity:1;stroke:none" />
<path d="m 27,11 1,0 0,1 -1,0 z" style="fill:#ffffff;fill-opacity:1;stroke:none" />
<path d="m 20,13 0,-1 1,0 0,-1 1,0 0,2 z" style="fill:#000000;fill-opacity:1;stroke:none" />
<path d="m 20,11 1,0 0,1 -1,0 z" style="fill:#ffffff;fill-opacity:1;stroke:none" />
</g>
</g>
</g>
</svg>
</div>
<div id="rbw">
<div class="w">
<div class="rbw f1">
<div class="wv wv-1"></div><div class="wv wv-2"></div><div class="wv wv-3"></div>
<div class="wv wv-4"></div><div class="wv wv-5"></div><div class="wv wv-6"></div>
</div>
<div class="rbw f2">
<div class="wv wv-1"></div><div class="wv wv-2"></div><div class="wv wv-3"></div>
<div class="wv wv-4"></div><div class="wv wv-5"></div><div class="wv wv-6"></div>
</div>
<div class="rbw f3">
<div class="wv wv-1"></div><div class="wv wv-2"></div><div class="wv wv-3"></div>
<div class="wv wv-4"></div><div class="wv wv-5"></div><div class="wv wv-6"></div>
</div>
<div class="rbw f4">
<div class="wv wv-1"></div><div class="wv wv-2"></div><div class="wv wv-3"></div>
<div class="wv wv-4"></div><div class="wv wv-5"></div><div class="wv wv-6"></div>
</div>
<div class="rbw f5">
<div class="wv wv-1"></div><div class="wv wv-2"></div><div class="wv wv-3"></div>
<div class="wv wv-4"></div><div class="wv wv-5"></div><div class="wv wv-6"></div>
</div>
<div class="rbw f6">
<div class="wv wv-1"></div><div class="wv wv-2"></div><div class="wv wv-3"></div>
<div class="wv wv-4"></div><div class="wv wv-5"></div><div class="wv wv-6"></div>
</div>
</div>
</div>
</div>
`;
}
}
customElements.define("data-nyancat", NyanCat);
function CSS() {
return `
#n-lder{ max-width: 100%; overflow: hidden; }
#n-lder #cat{ position: absolute; top: calc(50% + 45px); left: 0%; margin-left: -250px; margin-top: -125px; width: 100%; height: 150px; }
#n-lder.loading #cat{ left: 20%; left: calc(50% + 125px); transition: left 4s ease-out; }
#n-lder.loading.done #cat { left: 100%; left: calc(100% + 250px); transition: left 0.5s linear; }
#n-lder #cat svg{ height: 160px; width: 250px; position: absolute; }
#n-lder #cat #hide-behind{ position: absolute; top: 0; left: 55px; bottom: 0; right: -250px; }
#n-lder #rbw{ position: absolute; top: calc(50% + 45px); left: 0; overflow: hidden; height: 145px; margin-top: -110px; width: 100%; }
#n-lder #rbw .w{ width: 10000px; }
#n-lder #rbw .rbw { z-index: -1; font-size: 16em; float: left; position: relative; }
#n-lder #rbw .rbw .wv { height: 20px; width: 55px; }
#n-lder #rbw .rbw .wv.wv-1 { background: #ff0000; }
#n-lder #rbw .rbw .wv.wv-2 { background: #ff9900; }
#n-lder #rbw .rbw .wv.wv-3 { background: #ffff00; }
#n-lder #rbw .rbw .wv.wv-4 { background: #33ff00; }
#n-lder #rbw .rbw .wv.wv-5 { background: #0099ff; }
#n-lder #rbw .rbw .wv.wv-6 { background: #6633ff; }
#n-lder #rbw .rbw{ top: 0px; animation: rbw .6s linear infinite; }
#n-lder #rbw .rbw.f1{ animation-delay: 0s; }
#n-lder #rbw .rbw.f2{ animation-delay: 0.1s; }
#n-lder #rbw .rbw.f3{ animation-delay: 0.2s; }
#n-lder #rbw .rbw.f4{ animation-delay: 0.3s; }
#n-lder #rbw .rbw.f5{ animation-delay: 0.4s; }
#n-lder #rbw .rbw.f6{ animation-delay: 0.5s; }
@keyframes rbw {
0%{ top: 0px; }
50%{ top: 15px; }
100%{ top: 0px; }
}
@keyframes nyan_all {
0%{ transform: translateY(0px); }
33%{ transform: translateY(0px); }
34%{ transform: translateY(1px); }
100%{ transform: translateY(1px); }
} #n-lder svg g#nyan_all{ animation: nyan_all 0.40s linear infinite; }
@keyframes nyan_head {
0%{ transform: translateX(0px) translateY(0px); }
16%{ transform: translateX(0px) translateY(0px); }
17%{ transform: translateX(1px) translateY(0px); }
66%{ transform: translateX(1px) translateY(0px); }
67%{ transform: translateX(0px) translateY(0px); }
83%{ transform: translateX(0px) translateY(0px); }
84%{ transform: translateX(0px) translateY(-1px); }
100%{ transform: translateX(0px) translateY(-1px); }
} #n-lder svg g#nyan_head{ animation: nyan_head 0.4s linear infinite; }
@keyframes nyan_walk {
0%{ transform: translateX(0px); }
16%{ transform: translateX(0px); }
17%{ transform: translateX(1px); }
33%{ transform: translateX(1px); }
34%{ transform: translateX(2px); }
50%{ transform: translateX(2px); }
51%{ transform: translateX(1px); }
100%{ transform: translateX(0px); }
} #n-lder svg g#nyan_feet{ animation: nyan_walk 0.5s linear infinite; }
@keyframes nyan_tail {
0%{ transform: rotate(0); }
16%{ transform: rotate(0); }
17%{ transform: rotate(-5deg); }
33%{ transform: rotate(-5deg); }
34%{ transform: rotate(-10deg); }
49%{ transform: rotate(-10deg); }
50%{ transform: rotate(-20deg); }
66%{ transform: rotate(-20deg); }
67%{ transform: rotate(-10deg); }
83%{ transform: rotate(-10deg); }
84%{ transform: rotate(-5deg); }
99%{ transform: rotate(-5deg); }
100%{ transform: rotate(0deg); }
} #n-lder svg g#nyan_tail{ animation: nyan_tail 0.5s linear infinite; transform-origin: 4px 8px; }
`;
};

View file

@ -0,0 +1,3 @@
export default function(render) {
render(`<h1>HOMEPAGE</h1>`);
};

View file

@ -0,0 +1,7 @@
import home from "./index.js";
test("home", () => {
const render = jest.fn();
home(render);
expect(render).toBeCalledTimes(1);
});

90
public/server.go Normal file
View file

@ -0,0 +1,90 @@
package main
import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/http/httputil"
"net/url"
"path/filepath"
"strings"
"time"
)
const (
CHROOT = "/"
ENDPOINT = "127.0.0.1:8000"
)
func main() {
fmt.Printf("starting server http://%s ...\n", ENDPOINT)
err := http.ListenAndServe(ENDPOINT, http.HandlerFunc(serveStatic))
if err != nil {
fmt.Printf("Oops %s\n", err.Error())
}
}
func serveStatic(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/proxy/") {
u, _ := url.Parse("http://127.0.0.1:8334")
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/proxy")
httputil.NewSingleHostReverseProxy(u).ServeHTTP(w, r)
return
}
var (
fs http.Dir
f http.File
err error
)
defer func() {
now := time.Now().Format("2006-01-02 15:04:05")
if err != nil {
fmt.Printf("%s HTTP %s %s err[%s]\n", now, r.Method, r.URL.Path, err.Error())
return
}
fmt.Printf("%s HTTP %s %s\n", now, r.Method, r.URL.Path)
}()
fs = http.Dir(".")
if strings.HasSuffix(r.URL.Path, "/") {
http.FileServer(fs).ServeHTTP(w, r)
return
}
f, err = openFile(fs, r, w)
if err != nil {
r.URL.Path = CHROOT + "index.html"
f, err = openFile(fs, r, w)
}
if err != nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Oops"))
return
}
// TODO: etag
w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(r.URL.Path)))
io.Copy(w, f)
f.Close()
}
func openFile(fs http.Dir, r *http.Request, w http.ResponseWriter) (http.File, error) {
encs := [][]string{}
if strings.Contains(r.Header.Get("Accept-Encoding"), "br") {
encs = append(encs, []string{".br", "br"})
}
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
encs = append(encs, []string{".gz", "gzip"})
}
encs = append(encs, []string{"", ""})
for _, enc := range encs {
f, err := fs.Open(r.URL.Path + enc[0])
if err == nil {
if enc[1] != "" {
w.Header().Set("Content-Encoding", enc[1])
}
return f, nil
}
}
return nil, errors.New("not found")
}