diff --git a/public/assets/pages/adminpage/ctrl_login.js b/public/assets/pages/adminpage/ctrl_login.js index f443fe71..6af58eb0 100644 --- a/public/assets/pages/adminpage/ctrl_login.js +++ b/public/assets/pages/adminpage/ctrl_login.js @@ -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 diff --git a/public/assets/pages/adminpage/decorator_admin_only.js b/public/assets/pages/adminpage/decorator_admin_only.js index 080a765f..1c2f8b37 100644 --- a/public/assets/pages/adminpage/decorator_admin_only.js +++ b/public/assets/pages/adminpage/decorator_admin_only.js @@ -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)), )); }; } diff --git a/public/assets/pages/connectpage/component_forkme.js b/public/assets/pages/connectpage/component_forkme.js deleted file mode 100644 index 512c8352..00000000 --- a/public/assets/pages/connectpage/component_forkme.js +++ /dev/null @@ -1,25 +0,0 @@ -import { createElement } from "../../lib/skeleton/index.js"; - -export default createElement(` - - - - -`); diff --git a/public/assets/pages/connectpage/component_poweredby.js b/public/assets/pages/connectpage/component_poweredby.js deleted file mode 100644 index 0aa4fd30..00000000 --- a/public/assets/pages/connectpage/component_poweredby.js +++ /dev/null @@ -1,29 +0,0 @@ -import { createElement } from "../../lib/skeleton/index.js"; -import t from "../../lib/locales.js"; - -export default createElement(` -
- ${t("Powered by")} Filestash - -
-`); diff --git a/public/assets/pages/connectpage/ctrl_form.css b/public/assets/pages/connectpage/ctrl_form.css index dfb0f8ca..17dca515 100644 --- a/public/assets/pages/connectpage/ctrl_form.css +++ b/public/assets/pages/connectpage/ctrl_form.css @@ -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; diff --git a/public/assets/pages/connectpage/ctrl_form.js b/public/assets/pages/connectpage/ctrl_form.js index 867d57b9..969ab952 100644 --- a/public/assets/pages/connectpage/ctrl_form.js +++ b/public/assets/pages/connectpage/ctrl_form.js @@ -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(`
- +
`); + 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(``)), - 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(``))), + 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("
"), + renderLeaf: ({ label, type }) => { + if (type === "enable") return createElement(` + + `); + return createElement(""); + } + }))), + applyMutation(qs($page, "form"), "replaceChildren"), + rxjs.tap(() => animate($page.querySelector("form > div"), { time: 200, keyframes: slideYIn(2) })), + rxjs.tap(() => qs($page, "form").appendChild(createElement(``))), + )); + + // 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("
"), - renderLeaf: ({ label, type }) => { - if (type === "enable") { - return createElement(` - - `); - } - return createElement(""); - } - }); - 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(""))) - )); - - // 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 } diff --git a/public/assets/pages/connectpage/model_backend.js b/public/assets/pages/connectpage/model_backend.js index ef997d03..cb97ad04 100644 --- a/public/assets/pages/connectpage/model_backend.js +++ b/public/assets/pages/connectpage/model_backend.js @@ -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), +); diff --git a/public/assets/pages/connectpage/model_config.js b/public/assets/pages/connectpage/model_config.js index 5b44617b..cdf7c668 100644 --- a/public/assets/pages/connectpage/model_config.js +++ b/public/assets/pages/connectpage/model_config.js @@ -6,5 +6,5 @@ export default ajax({ responseType: "json" }).pipe( rxjs.map(({ responseJSON }) => responseJSON.result), - rxjs.share() + rxjs.shareReplay(1), ); diff --git a/public/assets/pages/connectpage/state.js b/public/assets/pages/connectpage/state.js deleted file mode 100644 index acd3580a..00000000 --- a/public/assets/pages/connectpage/state.js +++ /dev/null @@ -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(); -} diff --git a/public/assets/pages/ctrl_connectpage.js b/public/assets/pages/ctrl_connectpage.js index 8b21bc41..64aab24c 100644 --- a/public/assets/pages/ctrl_connectpage.js +++ b/public/assets/pages/ctrl_connectpage.js @@ -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(`
+
-
`); + 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; diff --git a/public/assets/pages/ctrl_error.css b/public/assets/pages/ctrl_error.css deleted file mode 100644 index 4158170d..00000000 --- a/public/assets/pages/ctrl_error.css +++ /dev/null @@ -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); -} diff --git a/public/assets/pages/ctrl_error.js b/public/assets/pages/ctrl_error.js index d22167ae..8ce86692 100644 --- a/public/assets/pages/ctrl_error.js +++ b/public/assets/pages/ctrl_error.js @@ -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); +} +` diff --git a/public/assets/pages/ctrl_filespage.js b/public/assets/pages/ctrl_filespage.js index 492b14bb..1bd9777e 100644 --- a/public/assets/pages/ctrl_filespage.js +++ b/public/assets/pages/ctrl_filespage.js @@ -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"; diff --git a/public/assets/pages/ctrl_homepage.js b/public/assets/pages/ctrl_homepage.js index 6ca362c9..bf57efa5 100644 --- a/public/assets/pages/ctrl_homepage.js +++ b/public/assets/pages/ctrl_homepage.js @@ -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("")); + + // 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("")); - + // 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)), )); }; diff --git a/public/tsconfig.json b/public/tsconfig.json index 0573d3ba..47075a8b 100644 --- a/public/tsconfig.json +++ b/public/tsconfig.json @@ -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" ] }