chore (refactoring): error and connection page

This commit is contained in:
MickaelK 2023-11-30 00:52:22 +11:00
parent 792ef4ddfb
commit bc117af48d
15 changed files with 226 additions and 260 deletions

View file

@ -45,8 +45,7 @@ export default async function(render) {
if (err instanceof AjaxError) {
switch (err.code()) {
case "INTERNAL_SERVER_ERROR":
ctrlError(render)(err);
return rxjs.EMPTY;
return rxjs.throwError(err);
case "FORBIDDEN":
return rxjs.of(false);
}
@ -61,7 +60,8 @@ export default async function(render) {
rxjs.mapTo(["name", "arrow_right"]), applyMutation(qs($form, "component-icon"), "setAttribute"),
rxjs.mapTo(""), stateMutation(qs($form, "[name=\"password\"]"), "value"),
rxjs.mapTo(["error"]), applyMutation(qs($form, ".input_group"), "classList", "add"),
rxjs.delay(300), applyMutation(qs($form, ".input_group"), "classList", "remove")
rxjs.delay(300), applyMutation(qs($form, ".input_group"), "classList", "remove"),
rxjs.catchError(ctrlError(render)),
));
// feature: autofocus

View file

@ -8,14 +8,8 @@ export default function AdminOnly(ctrlWrapped) {
return (render) => {
effect(isAdmin$().pipe(
rxjs.map((isAdmin) => isAdmin ? ctrlWrapped : ctrlLogin),
rxjs.catchError((err) => {
if (err instanceof AjaxError && err.code() === "INTERNAL_SERVER_ERROR") {
ctrlError(err)(render);
return rxjs.EMPTY;
}
return rxjs.of(ctrlError(err));
}),
rxjs.tap((ctrl) => ctrl(render)),
rxjs.catchError(ctrlError(render)),
));
};
}

View file

@ -1,25 +0,0 @@
import { createElement } from "../../lib/skeleton/index.js";
export default createElement(`
<a href="https://github.com/mickael-kerjean/skeleton" class="component_forkme" aria-label="View source on GitHub">
<svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--color); color:#fff; position: absolute; top: 0; border: 0; right: 0;" aria-hidden="true">
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
<path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path>
<path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path>
</svg>
<style>
.component_forkme:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}
.dark-mode .component_forkme .octo-arm,
.dark-mode .component_forkme .octo-body { fill: var(--bg-color); }
@media (max-width:500px){
.component_forkme:hover .octo-arm{animation:none}
.component_forkme .octo-arm{animation:octocat-wave 560ms ease-in-out}
}
@keyframes octocat-wave{
0%,100%{transform:rotate(0)}
20%,60%{transform:rotate(-25deg)}
40%,80%{transform:rotate(10deg)}
}
</style>
</a>
`);

View file

@ -1,29 +0,0 @@
import { createElement } from "../../lib/skeleton/index.js";
import t from "../../lib/locales.js";
export default createElement(`
<div class="component_poweredbyfilestash">
${t("Powered by")} <strong><a href="https://www.filestash.app">Filestash</a></strong>
<style>
.component_poweredbyfilestash{
display: inline-block;
color: rgba(0, 0, 0, 0.4);
font-size: 0.9em;
line-height: 20px;
position: fixed;
bottom: 10px;
right: 20px;
}
.component_poweredbyfilestash strong{
font-weight: normal;
}
.component_poweredbyfilestash strong a{
text-decoration: underline;
}
.dark-mode .component_poweredbyfilestash {
color: var(--light);
}
</style>
</div>
`);

View file

@ -54,6 +54,7 @@
color: white;
text-transform: uppercase;
}
.component_page_connection_form form .third-party {
text-align: center;
}
@ -71,17 +72,6 @@
margin: 45px 0;
}
.component_page_connection_form.form-appear {
opacity: 0;
transform: translateX(5px);
}
.component_page_connection_form.form-appear.form-appear-active {
opacity: 1;
transform: translateX(0);
transition: transform 0.25s ease-out, opacity 0.5s ease-out;
}
.scroll-x {
overflow-x: auto !important;
overflow-y: hidden !important;

View file

@ -1,110 +1,162 @@
import { createElement, navigate } from "../../lib/skeleton/index.js";
import rxjs, { effect, applyMutation, preventDefault } from "../../lib/rx.js";
import rxjs, { effect, applyMutation, applyMutations, preventDefault, onClick } from "../../lib/rx.js";
import ajax from "../../lib/ajax.js";
import { qs, qsa, safe } from "../../lib/dom.js";
import { animate, slideYIn } from "../../lib/animate.js";
import { animate, slideYIn, transition } from "../../lib/animate.js";
import { createForm } from "../../lib/form.js";
import { settings_get, settings_put } from "../../lib/settings.js";
import t from "../../lib/locales.js";
import { formTmpl } from "../../components/form.js";
import { CSS } from "../../helpers/loader.js";
import { createSession } from "../../model/session.js";
import ctrlError from "../ctrl_error.js";
import config$ from "./model_config.js";
import backend$ from "./model_backend.js";
import { setCurrentBackend, getCurrentBackend } from "./state.js";
import { setCurrentBackend, getCurrentBackend } from "./ctrl_form_state.js";
const connections$ = config$.pipe(
rxjs.map(({ connections }) => connections || []),
rxjs.shareReplay(1),
);
export default async function(render) {
const $page = createElement(`
<div class="no-select component_page_connection_form">
<div role="navigation" class="buttons scroll-x box"></div>
<style>${await CSS(import.meta.url, "ctrl_form.css")}</style>
<div role="navigation" class="buttons scroll-x box"></div>
<div class="box">
<form></form>
</div>
</div>
`);
render(transition($page, {
enter: [
{ transform: "scale(0.97)", opacity: 0 },
{ transform: "scale(1)", opacity: 1 },
],
timeEnter: 100,
}));
// feature1: backend selector
effect(config$.pipe(
// dom creation
rxjs.map(({ connections }) => connections),
rxjs.mergeMap((conns) => conns.map((conn, i) => ({ ...conn, n: i }))),
rxjs.map(({ label, n }) => createElement(`<button class="" data-current="${n}">${safe(label)}</button>`)),
rxjs.map(($button) => [$button]), applyMutation(qs($page, "[role=\"navigation\"]"), "appendChild"),
// initialise selection
rxjs.toArray(),
rxjs.map((conns) => Math.max(0, conns.length / 2 - 1)),
rxjs.tap((current) => setCurrentBackend(current))
// feature1: create navigation buttons to select storage
effect(connections$.pipe(
rxjs.map((conns) => conns.map((conn, i) => ({ ...conn, n: i }))),
rxjs.map((conns) => conns.map(({ label, n }) => createElement(`<button data-current="${n}">${safe(label)}</button>`))),
applyMutations(qs($page, "[role=\"navigation\"]"), "appendChild"),
));
// feature2: interaction with the buttons
// feature2: select a default storage among all the available ones
effect(connections$.pipe(
rxjs.map((conns) => {
let n = parseInt(settings_get("login_tab"));
if (Number.isNaN(n)) n = (conns.length || 0) / 2 - 1;
if (n < 0 || n >= conns.length) n = 0;
return n
}),
rxjs.tap((current) => setCurrentBackend(Math.round(current))),
));
// feature3: create the storage forms
const formSpecs$ = connections$.pipe(rxjs.mergeMap((conns) => backend$.pipe(
rxjs.map((backendSpecs) => conns.map(({ type }) => backendSpecs[type])),
)));
effect(getCurrentBackend().pipe(
rxjs.mergeMap((n) => formSpecs$.pipe(
rxjs.map((specs) => specs[n]),
)),
rxjs.mergeMap((formSpec) => createForm(formSpec, formTmpl({
renderNode: () => createElement("<div></div>"),
renderLeaf: ({ label, type }) => {
if (type === "enable") return createElement(`
<label class="advanced">
<span data-bind="children"></span>
${label}
</label>
`);
return createElement("<label></label>");
}
}))),
applyMutation(qs($page, "form"), "replaceChildren"),
rxjs.tap(() => animate($page.querySelector("form > div"), { time: 200, keyframes: slideYIn(2) })),
rxjs.tap(() => qs($page, "form").appendChild(createElement(`<button class="emphasis full-width">${t("CONNECT")}</button>`))),
));
// feature4: interaction with the nav buttons
effect(getCurrentBackend().pipe(
rxjs.first(),
rxjs.map(() => qsa($page, "[role=\"navigation\"] button")),
rxjs.mergeMap((els) => els),
rxjs.mergeMap(($button) => rxjs.fromEvent($button, "click")),
rxjs.map((e) => parseInt(e.target.getAttribute("data-current"))),
rxjs.tap((current) => setCurrentBackend(current))
));
// feature3: highlight the selected button
effect(getCurrentBackend().pipe(
rxjs.map((n) => ({ $buttons: qsa($page, "[role=\"navigation\"] button"), n })),
rxjs.tap(({ $buttons }) => $buttons.forEach(($node) => $node.classList.remove("active", "primary"))),
rxjs.map(({ $buttons, n }) => $buttons[n]),
rxjs.filter(($button) => !!$button),
rxjs.tap(($button) => $button.classList.add("active", "primary"))
));
// feature4: insert all the connection form
const tmpl = formTmpl({
renderNode: () => createElement("<div></div>"),
renderLeaf: ({ label, type }) => {
if (type === "enable") {
return createElement(`
<label class="advanced">
<span data-bind="children"></span>
${label}
</label>
`);
}
return createElement("<label></label>");
}
});
effect(rxjs.combineLatest(
config$.pipe(
rxjs.first(),
rxjs.mergeMap(({ connections }) => connections),
rxjs.mergeMap(({ type }) => backend$.pipe(rxjs.map((spec) => spec[type]))),
rxjs.mergeMap((formSpec) => createForm(formSpec, tmpl)),
rxjs.toArray(),
rxjs.share()
),
getCurrentBackend()
).pipe(
rxjs.map(([$forms, n]) => [$forms[n]]),
applyMutation(qs($page, "form"), "replaceChildren"),
rxjs.tap(() => animate($page.querySelector("form > div"), { time: 200, keyframes: slideYIn(-2) })),
rxjs.tap(() => qs($page, "form").appendChild(createElement("<button class=\"emphasis full-width\">CONNECT</button>")))
));
// feature5: form submission
effect(rxjs.fromEvent(qs($page, "form"), "submit").pipe(
preventDefault(),
rxjs.map((e) => new FormData(e.target)),
rxjs.map((formData) => {
const json = {};
for (const pair of formData.entries()) {
json[pair[0]] = pair[1] === "" ? null : pair[1];
}
return json;
rxjs.mergeMap(() => qsa($page, "[role=\"navigation\"] button")),
rxjs.mergeMap(($button) => onClick($button)),
rxjs.map(($button) => parseInt($button.getAttribute("data-current"))),
rxjs.distinctUntilChanged(),
rxjs.tap((current) => {
settings_put("login_tab", current);
setCurrentBackend(current);
}),
// rxjs.map((formData) => JSON.parse(JSON.stringify(a, (key, value) => {
// if (value !== null) return value;
// })),
rxjs.mergeMap((creds) => createSession(creds)),
rxjs.tap(() => navigate("/")),
// TODO: error with notification
));
render($page);
// feature5: highlight the currently selected storage
effect(getCurrentBackend().pipe(
rxjs.map((n) => [qsa($page, "[role=\"navigation\"] button"), n]),
rxjs.tap(([$buttons, n]) => $buttons.forEach(($button, i) => {
if (i !== n) $button.classList.remove("active", "primary");
else $button.classList.add("active", "primary");
})),
));
// feature6: form submission
effect(rxjs.merge(
// 6.a submit when url has a type key
rxjs.of([...new URLSearchParams(location.search)]).pipe(
rxjs.filter((arr) => arr.find(([key, _]) => key === "type")),
rxjs.map((arr) => arr.reduce((acc, el) => {
acc[el[0]] = el[1]
return acc;
}, {})),
),
// 6.b submit on pressing the submit button in the form
rxjs.fromEvent(qs($page, "form"), "submit").pipe(
preventDefault(),
rxjs.map((e) => new FormData(e.target)),
rxjs.map((formData) => {
const json = {};
for (const pair of formData.entries()) {
json[pair[0]] = pair[1] === "" ? null : pair[1];
}
return json;
}),
),
).pipe(
rxjs.mergeMap((formData) => { // CASE 1: authentication middleware flow
// TODO
return rxjs.of(formData);
}),
rxjs.mergeMap((formData) => { // CASE 2: oauth2 related backends like dropbox and gdrive
if (!("oauth2" in formData)) return rxjs.of(formData);
return new rxjs.Observable((subscriber) => {
const u = new URL(location.toString());
u.pathname = formData["oauth2"];
const _next = new URLSearchParams(location.search).get("next");
if (_next) u.searchParams.set("next", _next);
subscriber.next(u.toString());
}).pipe(
rxjs.tap((a) => console.log(a)),
rxjs.mergeMap((url) => ajax({ url, responseType: "json" })),
// TODO: loading
rxjs.tap(({ responseJSON }) => location.href = responseJSON.result),
rxjs.catchError(ctrlError(render)),
rxjs.mergeMap(() => rxjs.EMPTY),
);
}),
rxjs.mergeMap((formData) => { // CASE 3: regular login
console.log(formData);
return createSession(formData).pipe(
rxjs.tap(() => navigate("/")), // TODO: home and next redirect
);
// return rxjs.EMPTY;
}),
));
// TODO submit when there's only 1 backend defined through auth. middleware
// feature: clear the cache
}

View file

@ -4,4 +4,7 @@ import ajax from "../../lib/ajax.js";
export default ajax({
url: "/api/backend",
responseType: "json"
}).pipe(rxjs.map(({ responseJSON }) => responseJSON.result));
}).pipe(
rxjs.map(({ responseJSON }) => responseJSON.result),
rxjs.shareReplay(1),
);

View file

@ -6,5 +6,5 @@ export default ajax({
responseType: "json"
}).pipe(
rxjs.map(({ responseJSON }) => responseJSON.result),
rxjs.share()
rxjs.shareReplay(1),
);

View file

@ -1,11 +0,0 @@
import rxjs from "../../lib/rx.js";
const currentBackend$ = new rxjs.Subject();
export function setCurrentBackend(n) {
currentBackend$.next(n);
}
export function getCurrentBackend() {
return currentBackend$.asObservable();
}

View file

@ -4,50 +4,37 @@ import { qs } from "../lib/dom.js";
import { CSS } from "../helpers/loader.js";
import ctrlForm from "./connectpage/ctrl_form.js";
import config$ from "./connectpage/model_config.js";
import $fork from "./connectpage/component_forkme.js";
import $poweredby from "./connectpage/component_poweredby.js";
import ctrlForkme from "./connectpage/ctrl_forkme.js";
import ctrlPoweredby from "./connectpage/ctrl_poweredby.js";
export default async function(render) {
const $page = createElement(`
<div class="component_page_connect">
<style>${await CSS(import.meta.url, "ctrl_connectpage.css")}</style>
<div data-bind="component_forkme"></div>
<div data-bind="centerthis" class="component_page_connection_form component_container" style="max-width:565px;">
<div data-bind="component_form"></div>
</div>
<div data-bind="component_poweredby"></div>
<style>${await CSS(import.meta.url, "ctrl_connectpage.css")}</style>
</div>
`);
render($page);
// feature1: connection form
// feature1: forkme & poweredby button
ctrlForkme(createRender(qs($page, "[data-bind=\"component_forkme\"]")));
ctrlPoweredby(createRender(qs($page, "[data-bind=\"component_poweredby\"]")));
await new Promise((done) => setTimeout(done, 300));
// feature2: connection form
ctrlForm(createRender(qs($page, "[data-bind=\"component_form\"]")));
// feature2: forkme button
effect(config$.pipe(
rxjs.filter(({ fork_button }) => fork_button !== false),
rxjs.mapTo([$fork]),
applyMutation(qs($page, "[data-bind=\"component_forkme\"]"), "appendChild")
));
// feature3: poweredby button
effect(config$.pipe(
rxjs.filter(({ fork_button }) => fork_button !== false),
rxjs.mapTo([$poweredby]),
applyMutation(qs($page, "[data-bind=\"component_poweredby\"]"), "appendChild")
));
// feature4: center the form
// feature3: center the form
effect(rxjs.fromEvent(window, "resize").pipe(
rxjs.startWith(null),
rxjs.map(() => {
let size = 300;
const $screen = document.querySelector(".login-form");
if ($screen instanceof window.HTMLElement) size = $screen.offsetHeight;
size = Math.round((document.body.offsetHeight - size) / 2);
const h = 400;
const size = Math.round((document.body.offsetHeight - h) / 2);
if (size < 0) return 0;
if (size > 150) return 150;
return size;

View file

@ -1,49 +0,0 @@
.error-page {
width: 80%;
max-width: 600px;
margin: 50px auto 0 auto;
flex-direction: column;
}
.error-page h1 {
margin: 5px 0;
font-size: 3.1em;
}
.error-page h2 {
margin: 10px 0;
font-weight: normal;
font-weight: 100;
}
.error-page code {
margin-top: 5px;
display: block;
padding: 10px;
overflow-x: auto;
background: #e2e2e2;
color: var(--dark);
border-radius: 3px;
}
.error-page pre {
margin: 0;
}
.error-page p {
font-style: italic;
margin-bottom: 5px;
}
.error-page a {
border-bottom: 1px dashed;
}
.backnav {
font-weight: 100;
display: inline-block;
padding: 10px 5px;
}
.backnav .component_icon {
height: 23px;
margin-right: -3px;
vertical-align: middle;
}
.dark-mode .error-page, .dark-mode .backnav {
color: rgba(255, 255, 255, 0.8);
}

View file

@ -2,14 +2,11 @@ import { createElement } from "../lib/skeleton/index.js";
import rxjs, { effect, applyMutation } from "../lib/rx.js";
import { qs } from "../lib/dom.js";
import t from "../lib/locales.js";
import { CSS } from "../helpers/loader.js";
import { AjaxError, ApplicationError } from "../lib/error.js";
import "../components/icon.js";
const css = await CSS(import.meta.url, "ctrl_error.css")
export default function(render) {
return async function(err) {
const [msg, trace] = processError(err);
@ -74,3 +71,56 @@ trace: ${err.stack || "N/A"}`;
}
return [msg, trace.trim()];
}
const css = `
.error-page {
width: 80%;
max-width: 600px;
margin: 50px auto 0 auto;
flex-direction: column;
}
.error-page h1 {
margin: 5px 0;
font-size: 3.1em;
}
.error-page h2 {
margin: 10px 0;
font-weight: normal;
font-weight: 100;
}
.error-page code {
margin-top: 5px;
display: block;
padding: 10px;
overflow-x: auto;
background: #e2e2e2;
color: var(--dark);
border-radius: 3px;
}
.error-page pre {
margin: 0;
}
.error-page p {
font-style: italic;
margin-bottom: 5px;
}
.error-page a {
border-bottom: 1px dashed;
}
.backnav {
font-weight: 100;
display: inline-block;
padding: 10px 5px;
}
.backnav .component_icon {
height: 23px;
margin-right: -3px;
vertical-align: middle;
}
.dark-mode .error-page, .dark-mode .backnav {
color: rgba(255, 255, 255, 0.8);
}
`

View file

@ -1,7 +1,6 @@
import { createElement, createRender } from "../lib/skeleton/index.js";
import rxjs, { effect } from "../lib/rx.js";
import { CSS } from "../helpers/loader.js";
import ctrlError from "./ctrl_error.js";
import { getState$ } from "./filespage/state.js";
import componentFilesystem from "./filespage/filesystem.js";

View file

@ -1,6 +1,6 @@
import { createElement, navigate } from "../lib/skeleton/index.js";
import rxjs, { effect } from "../lib/rx.js";
import { ApplicationError } from "../lib/error.js";
import { ApplicationError, AjaxError } from "../lib/error.js";
import ctrlError from "./ctrl_error.js";
import { getSession } from "../model/session.js";
@ -8,26 +8,31 @@ import { getSession } from "../model/session.js";
import "../components/loader.js";
export default function(render) {
render(createElement("<component-loader></component-loader>"));
// feature1: trigger error page via url params
const GET = new URLSearchParams(location.search);
const err = GET.get("error");
if (err) {
ctrlError(new ApplicationError(
ctrlError(render)(new ApplicationError(
err,
GET.get("trace") || "server error from URL"
))(render);
GET.get("trace") || "server error from URL",
));
return;
}
render(createElement("<component-loader></component-loader>"));
// feature2: redirect user where it makes most sense
effect(getSession().pipe(
rxjs.catchError((err) => {
if (err instanceof AjaxError && err.err().status === 401) {
return rxjs.of({ is_authenticated: false });
}
return rxjs.throwError(err);
}),
rxjs.tap(({ is_authenticated, home = "/" }) => {
if (is_authenticated !== true) return navigate("/login");
return navigate(`/files${home}`);
}),
rxjs.catchError(() => {
navigate("/login");
return rxjs.EMPTY;
}),
rxjs.catchError(ctrlError(render)),
));
};

View file

@ -29,7 +29,7 @@
},
"input": ["pages/ctrl_boot.d.ts", "pages/*.js"],
"exclude": [
"**/*.test.js", "worker/sw_cache.js",
"**/*.test.js", "assets/worker/sw_cache.js",
"coverage", "jest.setup.js"
]
}