chore (rewrite): frontend rewrite

This commit is contained in:
MickaelK 2024-06-04 00:03:46 +10:00
parent 708ba9ea21
commit b142392307
22 changed files with 314 additions and 137 deletions

View file

@ -1,6 +1,5 @@
import rxjs, { ajax } from "../lib/rx.js";
// import { setup_cache } from "../helpers/cache.js";
import { init as setup_translation } from "../locales/index.js";
import { init as setup_loader, loadJS } from "../helpers/loader.js";
import { report } from "../helpers/log.js";
@ -50,6 +49,45 @@ function $error(msg) {
document.body.appendChild($code);
}
/// /////////////////////////////////////////
// boot steps helpers
function setup_translation() {
let selectedLanguage = "en";
switch (window.navigator.language) {
case "zh-TW":
selectedLanguage = "zh_tw";
break;
default:
const userLanguage = window.navigator.language.split("-")[0] || "en";
const idx = [
"az", "be", "bg", "ca", "cs", "da", "de", "el", "es", "et",
"eu", "fi", "fr", "gl", "hr", "hu", "id", "is", "it", "ja",
"ka", "ko", "lt", "lv", "mn", "nb", "nl", "pl", "pt", "ro",
"ru", "sk", "sl", "sr", "sv", "th", "tr", "uk", "vi", "zh"
].indexOf(window.navigator.language.split("-")[0] || "");
if (idx !== -1) {
selectedLanguage = userLanguage;
}
}
if (selectedLanguage === "en") {
return;
}
return ajax({
url: "/assets/locales/" + selectedLanguage + ".json",
responseType: "json"
}).pipe(
rxjs.tap(({ responseHeaders, response }) => {
const contentType = responseHeaders["content-type"].trim();
if (contentType !== "application/json") {
report("boot::translation", new Error(`wrong content type '${contentType}'`), "ctrl_boot_frontoffice.js");
return;
}
window.LNG = response;
})
).toPromise();
}
async function setup_xdg_open() {
window.overrides = {};
return loadJS(import.meta.url, "/overrides/xdg-open.js");
@ -62,7 +100,7 @@ async function setup_device() {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.body.classList.add("dark-mode");
}
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function(e) {
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function(e) {
e.matches ? document.body.classList.add("dark-mode") : document.body.classList.remove("dark-mode");
});
}

View file

@ -2,7 +2,7 @@ import { animate, slideYOut, slideYIn, opacityOut } from "../lib/animate.js";
import { loadCSS } from "../helpers/loader.js";
import { mv } from "../pages/filespage/model_files.js";
import { extractPath } from "../pages/filespage/helper.js";
import { extractPath, isDir } from "../pages/filespage/helper.js";
class ComponentBreadcrumb extends window.HTMLDivElement {
constructor() {
@ -171,7 +171,8 @@ class ComponentBreadcrumb extends window.HTMLDivElement {
let to = $path.getAttribute("data-path");
const [fromBasepath, fromName] = extractPath(from);
to += fromName + "/";
to += fromName;
if (isDir(from)) to += "/";
await mv(from, to).toPromise();
};
});

View file

@ -3,7 +3,7 @@ import ajax from "../../lib/ajax.js";
const release$ = ajax({
url: "/about",
responseType: "text"
responseType: "text",
}).pipe(rxjs.shareReplay(1));
export function get() {

View file

@ -124,7 +124,7 @@ const css = `
}
`;
function calculateBacklink(pathname) {
function calculateBacklink(pathname = "") {
let url = "/";
const listPath = pathname.replace(new RegExp("/$"), "").split("/");
switch (listPath[1]) {

View file

@ -3,6 +3,7 @@ import rxjs, { effect } from "../lib/rx.js";
import { qs } from "../lib/dom.js";
import { loadCSS } from "../helpers/loader.js";
import WithShell, { init as initShell } from "../components/decorator_shell_filemanager.js";
import { get as getConfig } from "./filespage/model_config.js";
import componentFilesystem, { init as initFilesystem } from "./filespage/ctrl_filesystem.js";
import componentSubmenu, { init as initSubmenu } from "./filespage/ctrl_submenu.js";
@ -38,7 +39,7 @@ export default WithShell(function(render) {
export function init() {
return Promise.all([
loadCSS(import.meta.url, "./ctrl_filespage.css"),
initShell(), initFilesystem(),
initShell(), initFilesystem(), getConfig().toPromise(),
initSubmenu(), initNewItem(), initUpload(),
]);
}

View file

@ -1,7 +1,7 @@
import { onDestroy } from "../../lib/skeleton/index.js";
import rxjs from "../../lib/rx.js";
import fscache from "./cache.js";
import { extractPath } from "./helper.js";
import { extractPath, isDir } from "./helper.js";
/*
* The transcient cache is used to rerender the list of files in a particular location. That's used
@ -215,7 +215,6 @@ export function mv(ajax$, fromPath, toPath) {
});
}
const onSuccess = async () => {
console.log(fromPath, toPath)
fscache().remove(fromPath, false);
if (fromBasepath === toBasepath) {
stateAdd(mutationFiles$, fromBasepath, {
@ -244,7 +243,21 @@ export function mv(ajax$, fromPath, toPath) {
return file;
},
});
removeLoading(virtualFiles$, toBasepath, toName);
onDestroy(() => statePop(mutationFiles$, fromBasepath, fromName));
statePop(virtualFiles$, toBasepath, toName);
await fscache().update(fromBasepath, ({ files, ...rest }) => ({
files: files.filter((file) => file.name === fromName ? false : true),
...rest,
}))
await fscache().update(toBasepath, ({ files, ...rest }) => ({
files: files.concat([{
name: fromName,
time: new Date().getTime(),
type,
}]),
...rest,
}));
if (isDir(fromPath)) await fscache.remove(fromPath);
}
};
const onFailure = () => {

View file

@ -269,7 +269,7 @@ export default async function(render) {
shift: false, files: [],
});
if (elm1) addSelection({
n: (files$.value || []).length,
n: (files$.value || []).length - 1,
path: path + elm1.name + (elm1.type === "directory" ? "/" : ""),
shift: true, files: (files$.value || []),
});

View file

@ -55,7 +55,7 @@ export default async function(render) {
function componentLeft(render, { $scroll }) {
effect(getSelection$().pipe(
rxjs.filter((selections) => selections.length === 0),
rxjs.filter(() => lengthSelection() === 0),
rxjs.mergeMap(() => rxjs.merge(rxjs.fromEvent(window, "resize"), rxjs.of(null))),
rxjs.mergeMap(() => getPermission()),
rxjs.map(() => render(createFragment(`
@ -87,15 +87,15 @@ function componentLeft(render, { $scroll }) {
onDestroy(() => setAction(null));
effect(getSelection$().pipe(
rxjs.filter((selections) => selections.length === 1),
rxjs.filter(() => lengthSelection() === 1),
rxjs.map(() => render(createFragment(`
<a ${generateLinkAttributes(expandSelection())}><button data-action="download" title="${t("Download")}">
<a target="_blank" ${generateLinkAttributes(expandSelection())}><button data-action="download" title="${t("Download")}">
${t("Download")}
</button></a>
<button data-action="delete"${toggleDependingOnPermission(currentPath(), "delete")} title="${t("Remove")}">
${t("Remove")}
</button>
<button data-action="share" title="${t("Share")}">
<button data-action="share" title="${t("Share")}" class="hidden">
${t("Share")}
</button>
<button data-action="embed" class="hidden" title="${t("Embed")}">
@ -152,9 +152,9 @@ function componentLeft(render, { $scroll }) {
));
effect(getSelection$().pipe(
rxjs.filter((selections) => selections.length > 1),
rxjs.filter((selections) => lengthSelection() > 1),
rxjs.map(() => render(createFragment(`
<a ${generateLinkAttributes(expandSelection())}><button data-action="download">
<a target="_blank" ${generateLinkAttributes(expandSelection())}><button data-action="download">
${t("Download")}
</button></a>
<button data-action="delete"${toggleDependingOnPermission(currentPath(), "delete")}>

View file

@ -7,13 +7,14 @@ import t from "../../locales/index.js";
export default function(render) {
const $page = createFragment(`
<div is="component_filezone"></div>
<div is="component_upload_queue"></div>
<div is="component_filezone"></div>
<div is="component_upload_fab"></div>
`);
componentFilezone(createRender($page.children[0]));
componentUploadQueue(createRender($page.children[1]));
componentFilezone(createRender($page.children[0]));
componentUploadFAB(createRender($page.children[2]));
render($page);
@ -25,7 +26,7 @@ export function init() {
function componentUploadQueue(render) {
const $page = createElement(`
<div class="component_upload hidden">
<div class="component_upload">
<h2>${t("Current Upload")} <div class="count_block">
<span class="completed">24</span>
<span class="grandTotal">24</span>

View file

@ -1,11 +1,17 @@
import { extname } from "../../lib/path.js";
const regexCurrentPath = new RegExp("^/files")
export function currentPath() {
return decodeURIComponent(location.pathname.replace(new RegExp("^/files"), ""));
return decodeURIComponent(location.pathname.replace(regexCurrentPath, ""));
}
const regexDir = new RegExp("/$");
export function isDir(path) {
return regexDir.test(path);
}
export function extractPath(path) {
path = path.replace(new RegExp("/$"), "");
path = path.replace(regexDir, "");
const p = path.split("/");
const filename = p.pop();
return [p.join("/") + "/", filename];

View file

@ -0,0 +1,8 @@
import rxjs from "../../lib/rx.js";
import { get as getConfig } from "../../model/config.js";
const config$ = getConfig().pipe(rxjs.shareReplay(1));
export function get() {
return config$;
}

View file

@ -22,40 +22,38 @@ import {
*/
export function touch(path) {
const ajax$ = rxjs.of(null).pipe(
rxjs.delay(1000),
// rxjs.tap(() => {
// throw new Error("NOOOO");
// }),
rxjs.delay(1000),
);
const ajax$ = ajax({
url: `/api/files/touch?path=${encodeURIComponent(path)}`,
method: "POST",
responseType: "json",
});
return cacheTouch(ajax$, path);
}
export function mkdir(path) {
const ajax$ = rxjs.of(null).pipe(
rxjs.delay(1000),
// rxjs.tap(() => {
// throw new Error("NOOOO");
// }),
rxjs.delay(1000),
);
const ajax$ = ajax({
url: `/api/files/mkdir?path=${encodeURIComponent(path)}`,
method: "POST",
responseType: "json",
});
return cacheMkdir(ajax$, path);
}
export function rm(...paths) {
const ajax$ = rxjs.of(null).pipe(
rxjs.delay(1000),
// rxjs.tap(() => {
// throw new Error("NOOOO");
// }),
rxjs.delay(1000),
);
const ajax$ = rxjs.forkJoin(paths.map((path) => ajax({
url: `/api/files/rm?path=${encodeURIComponent(path)}`,
method: "POST",
responseType: "json",
})));
return cacheRm(ajax$, ...paths);
}
export function mv(from, to) {
const ajax$ = rxjs.of(null).pipe(rxjs.delay(1000));
const ajax$ = ajax({
url: `/api/files/mv?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
method: "POST",
responseType: "json",
});
return cacheMv(ajax$, from, to);
}
@ -66,8 +64,9 @@ export function save(path) { // TODO
export function ls(path) {
const lsFromCache = (path) => rxjs.from(fscache().get(path));
const lsFromHttp = (path) => ajax({
url: `/api/files/ls?path=${path}`,
responseType: "json"
url: `/api/files/ls?path=${encodeURIComponent(path)}`,
method: "GET",
responseType: "json",
}).pipe(
rxjs.map(({ responseJSON }) => ({
files: responseJSON.results,
@ -114,7 +113,7 @@ export function ls(path) {
export function search(term) {
const path = location.pathname.replace(new RegExp("^/files/"), "/");
return ajax({
url: `/api/files/search?path=${path}&q=${term}`,
url: `/api/files/search?path=${encodeURIComponent(path)}&q=${encodeURIComponent(term)}`,
responseType: "json"
}).pipe(rxjs.map(({ responseJSON }) => ({
files: responseJSON.results,

View file

@ -1,32 +1,51 @@
import rxjs from "../../lib/rx.js";
import { onDestroy } from "../../lib/skeleton/index.js";
import { ApplicationError } from "../../lib/error.js";
import { currentPath } from "./helper.js";
import { currentPath, extractPath } from "./helper.js";
const selection$ = new rxjs.BehaviorSubject([]);
/*
* CAUTION: Use a lot of caution if you change this file as it's easy for a bug to slip in and be responsible
* for someone using selection to delete something and lose some data. We really don't want this to happen!
*
* BACKGROUND: There's many way we could have work on the selection. In fact I've made a couple prior attempts
* with very simple implementations but none of them felt "right" from a user point of view. The approach
* taken here mimicks the behavior of osx finder. To understand what is going on, I strongly advise you
* open up osx finder and play with it first.
*
* DATA STRUCTURE: The supporting data structure is a list containing 2 kind of elements:
* - "anchors" which is created when clicking on something with the cmd key
* - "range" which is when you click somewhere with the expectation it will expand your range
* using the shift key
*/
const selection$ = new rxjs.BehaviorSubject([
// { "type": "anchor", ... } => user click somewhere and the shift key is NOT pressed
// { "type": "range", ... } => ----------------------------------------- pressed
]);
onDestroy(clearSelection);
export function addSelection({ shift = false, n = 0, ...rest }) {
const newSelection = selection$.value;
let alreadyKnown = false;
for (let i=0; i<newSelection.length; i++) {
if (newSelection[i].n !== n) {
continue;
}
alreadyKnown = true;
if (newSelection[i].shift === shift) {
continue;
}
newSelection[i].shift = shift;
selection$.next(newSelection);
}
// console.log(n, shift)
const selections = selection$.value;
const selection = { type: shift ? "range" : "anchor", n, ...rest };
if (alreadyKnown === false) selection$.next(
selection$.value
.concat({ shift, n, ...rest })
.sort((prev, curr) => prev.n - curr.n)
);
// case1: select a single file/folder
if (selection.type === "anchor") {
selection$.next(selections.concat([selection]));
return
}
// case2: range selection
const last = selection$.value[selections.length - 1] || { n: 0, type: "anchor" };
if (last.type === "anchor") {
selection$.next(selections.concat([selection]));
return;
}
// clear out previous range selector. That's the behavior on apple finder when we do:
// [A,1] [R,3] = [A,1] [R,3] => expands to [1,2,3]
// [A,1] [R,3] [R,8] = [A,1] [R,8] => expands to [1,2,3,4,5,6,7,8]
// [A,1] [R,3] [R,8] [R,6] = [A,1] [R,6] => expands to [1,2,3,4,5,6]
selections[selections.length - 1] = selection;
selection$.next(selections);
}
export function clearSelection() {
@ -39,51 +58,72 @@ export function getSelection$() {
export function isSelected(n) {
let isChecked = false;
for (let i=0;i<selection$.value.length;i++) {
if (selection$.value[i]["n"] === n) isChecked = !isChecked;
else if (selection$.value[i]["shift"]
&& isBetween(n, selection$.value[i-1]?.n, selection$.value[i]?.n))
isChecked = !isChecked
const selections = selection$.value;
for (let i=0;i<selections.length;i++) {
if (selections[i].type === "anchor" && selections[i].n === n) {
isChecked = !isChecked;
}
else if (selections[i].type === "range" && isBetween(
n, selections[i-1]?.n || 0, selections[i]?.n,
)) {
isChecked = true; // WARNING: change with caution
}
}
return isChecked;
}
export function lengthSelection() {
const selections = selection$.value;
let l = 0;
for (let i=0; i<selections.length; i++) {
l += selections[i].shift && selections[i-1] ?
selections[i]["n"] - selections[i-1]["n"] :
1;
}
return l;
return _selectionHelper((set) => set.size);
}
export function expandSelection() {
const selections = [];
for (let i=0; i<selection$.value.length; i++) {
const curr = selection$.value[i];
if (curr.shift === false) {
selections.push({ path: curr.path });
continue;
return _selectionHelper((set) => {
const arr = new Array(set.size);
let i = 0;
for (const path of set) {
arr[i] = { path };
i += 1;
}
let prev = selection$.value[i-1];
if (!prev) prev = { n: 0 };
for (let i=1; i<= curr.n - prev.n; i++) {
if (i > 100000) throw new ApplicationError(
"Internal error",
`pages::state_selection.js curr=${curr.n} prev=${prev.n}`,
);
const path = currentPath();
const f = curr.files[prev.n + i];
selections.push({
path: path + f.name + (f.type === "directory" ? "/" : ""),
});
}
}
return selections;
// console.log(JSON.stringify(arr, null, 2));
return arr;
});
}
function isBetween(n, lowerBound, higherBound) {
return n < higherBound && n > lowerBound;
// This is the core function used to not only calculate the selection length but also expand the
// selection. We're quite slow with an algo running in 3N selection size with room for improvement
// but be very cautious, a bug in here could cause terrible consequence
function _selectionHelper(fn) {
const set = new Set();
const selections = selection$.value;
for (let i=0; i<selections.length; i++) {
const curr = selections[i];
if (selections[i].type === "anchor") {
if (isSelected(selections[i].n)) {
set.add(curr.path);
}
continue;
}
const [basepath] = extractPath(curr.path);
if (!selections[i-1]) {
for (let j=0; j<=selections[i].n; j++) {
if (isSelected(j) === false) continue;
const file = selections[i].files[j];
set.add(basepath + file.name + (file.type === "directory" ? "/" : ""));
}
} else {
const min = Math.min(selections[i].n, selections[i-1]?.n || 0);
const max = Math.max(selections[i].n, selections[i-1]?.n || 0);
for (let j=min; j<=max; j++) {
if (isSelected(j) === false) continue;
const file = selections[i].files[j];
set.add(basepath + file.name + (file.type === "directory" ? "/" : ""));
}
}
}
return fn(set);
}
function isBetween(n, lowerBound, higherBound) {
return n <= Math.max(higherBound, lowerBound) && n >= Math.min(lowerBound, higherBound);
}

View file

@ -122,6 +122,16 @@
margin: 0;
display: block;
}
.list > .component_thing.view-grid > img.component_icon.thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 50%;
background: var(--dark);
padding: 0;
margin: 0;
display: block;
}
.list > .component_thing.view-grid > img.component_icon {
padding: 30px;
box-sizing: border-box;
@ -131,14 +141,6 @@
margin: 0 auto;
z-index: 0;
}
.list > .component_thing.view-grid > img.thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 50%;
background: var(--dark);
z-index: 0;
}
.list > .component_thing.view-grid .info_extension {
position: absolute;
top: 45%;
@ -168,7 +170,7 @@
}
.list > .component_thing.view-grid .component_filename {
letter-spacing: -0.5px;
z-index: 2;
z-index: -1;
position: absolute;
bottom: 2px;
left: 2px;

File diff suppressed because one or more lines are too long

View file

@ -14,7 +14,7 @@ import { STLLoader } from "../../lib/vendor/three/STLLoader.js";
import { FBXLoader } from "../../lib/vendor/three/FBXLoader.js";
import { Rhino3dmLoader } from "../../lib/vendor/three/3DMLoader.js";
import ctrlError from "../ctrl_error.js";
import componentDownloader, { init as initDownloader } from "./application_downloader.js";
import "../../components/menubar.js";
@ -29,6 +29,13 @@ export default function(render, { mime }) {
const removeLoader = createLoader(qs($page, ".threeviewer_container"));
effect(rxjs.of(getLoader(mime)).pipe(
rxjs.mergeMap(([loader, createMesh]) => {
if (!loader) {
componentDownloader(render);
return rxjs.EMPTY;
}
return rxjs.of([loader, createMesh]);
}),
rxjs.mergeMap(([loader, createMesh]) => new rxjs.Observable((observer) => loader.load(
getDownloadUrl(),
(object) => observer.next(createMesh(object)),
@ -81,7 +88,6 @@ export default function(render, { mime }) {
renderer.render(scene, camera);
}));
}),
rxjs.catchError(ctrlError()),
));
}
@ -105,10 +111,13 @@ function getLoader(mime) {
case "application/fbx":
return [new FBXLoader(), identity];
default:
throw new Error(`Invalid loader for "${mime}"`);
return [null, null];
}
}
export function init() {
return loadCSS(import.meta.url, "./application_3d.css");
return Promise.all([
loadCSS(import.meta.url, "./application_3d.css"),
initDownloader(),
]);
}

View file

@ -3,7 +3,7 @@ import rxjs, { effect } from "../../lib/rx.js";
import { animate, slideXIn, opacityOut } from "../../lib/animate.js";
import { qs } from "../../lib/dom.js";
import { createLoader } from "../../components/loader.js";
import modal from "../../components/modal.js";
import { createModal } from "../../components/modal.js";
import { loadCSS, loadJS } from "../../helpers/loader.js";
import ajax from "../../lib/ajax.js";
import { extname } from "../../lib/path.js";
@ -169,7 +169,7 @@ export default async function(render) {
// },
// );
return new Promise((done) => {
modal.open(createElement(`
createModal(createElement(`
<div style="text-align:center;padding-bottom:5px;">
Do you want to save the changes ?
</div>

View file

@ -21,7 +21,7 @@ export default function(render) {
<div class="formviewer_container hidden">
<form class="sticky box"></form>
</div>
<button is="component-fab"></button>
<button is="component-fab" data-options="download"></button>
</div>
`);
render($page);

View file

@ -3,13 +3,14 @@ import rxjs, { effect } from "../../lib/rx.js";
import { loadCSS } from "../../helpers/loader.js";
import assert from "../../lib/assert.js";
import ctrlError from "../ctrl_error.js";
import notification from "../../components/notification.js";
import { getDownloadUrl } from "./common.js";
import { getCurrentPath } from "./common.js";
export default function(render, opts = {}) {
export default function(render, { endpoint = "" }) {
const $page = createElement(`
<div class="component_appframe">
<iframe src="${getDownloadUrl()}"></iframe>
<iframe src="${endpoint}?path=${encodeURIComponent(getCurrentPath())}"></iframe>
</div>
`);
render($page);
@ -19,10 +20,10 @@ export default function(render, opts = {}) {
rxjs.tap((event) => { // TODO: notification
switch (event.data.type) {
case "notify::info":
// notify.send(t(event.data.message), "info");
notify.info(t(event.data.message));
break;
case "notify::error":
// notify.send(t(event.data.message), "error");
notify.error(t(event.data.message));
break;
}
}),

View file

@ -22,7 +22,7 @@ export default function(render) {
<img class="photo idle hidden" draggable="true" src="${getDownloadUrl()}">
</div>
<div class="images_aside scroll-y"></div>
<div class="component_pager"></div>
<div class="component_pager hidden"></div>
</div>
</div>
`);

View file

@ -3,9 +3,7 @@
"allowJs": true,
"checkJs": true,
"noEmit": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,

18
public/vite.config.js Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
export default defineConfig(({ comand, mode }) => {
return {
plugins: [],
test: {
global: true,
environment: "jsdom",
setupFiles: ["./test/setup.js"],
},
coverage: {
reporter: ["text", "html"],
exclude: [
"./public/assets/lib/vendor/**"
],
}
};
});