diff --git a/public/assets/components/sidebar.css b/public/assets/components/sidebar.css index e2fd91de..bdf91a5e 100644 --- a/public/assets/components/sidebar.css +++ b/public/assets/components/sidebar.css @@ -133,7 +133,8 @@ body.touch-no .component_filemanager_shell .component_sidebar h3 img:hover { .component_filemanager_shell .component_sidebar [data-bind="taglist"] a > div { text-transform: none; } -.component_filemanager_shell .component_sidebar [data-bind="taglist"] a .hash { +.component_filemanager_shell .component_sidebar [data-bind="taglist"] a .hash:before { + content: "#"; font-size: 0.9rem; opacity: 0.5; } diff --git a/public/assets/components/sidebar.js b/public/assets/components/sidebar.js index 3a25e9c4..6f9dc3d0 100644 --- a/public/assets/components/sidebar.js +++ b/public/assets/components/sidebar.js @@ -1,7 +1,7 @@ import { createElement, createRender, onDestroy } from "../lib/skeleton/index.js"; import rxjs, { effect, onClick } from "../lib/rx.js"; import ajax from "../lib/ajax.js"; -import { fromHref, toHref } from "../lib/skeleton/router.js"; +import { toHref } from "../lib/skeleton/router.js"; import { qs, qsa, safe } from "../lib/dom.js"; import { forwardURLParams } from "../lib/path.js"; import { settingsGet, settingsSave } from "../lib/store.js"; @@ -14,16 +14,11 @@ import { extractPath, isDir, isNativeFileUpload } from "../pages/filespage/helpe import { mv as mvVL, withVirtualLayer } from "../pages/filespage/model_virtual_layer.js"; import { getCurrentPath } from "../pages/viewerpage/common.js"; import { generateSkeleton } from "./skeleton.js"; -import { onLogout } from "../pages/ctrl_logout.js"; -const state = { scrollTop: 0, $cache: null }; -const mv = (from, to) => withVirtualLayer( - mv$(from, to), - mvVL(from, to), -); - -export default async function ctrlSidebar(render, { nRestart = 0 }) { - if (!shouldDisplay()) return; +export default async function ctrlSidebar(render, {}) { + if (new URL(location.toString()).searchParams.get("nav") === "false") return; + else if (window.self !== window.top) return; + else if (document.body.clientWidth < 850) return; const $sidebar = render(createElement(`
@@ -31,15 +26,38 @@ export default async function ctrlSidebar(render, { nRestart = 0 }) { close -
-
+
+ ${generateSkeleton(2)} +
+
+ ${generateSkeleton(2)} +
`)); + withInstantLoad($sidebar); withResize($sidebar); + const path = getCurrentPath("(/view/|/files/)"); + + // fature: file navigation pane + const $files = qs($sidebar, `[data-bind="your-files"]`); + ctrlNavigationPane(createRender($files), { $sidebar, path }); + + // feature: tag viewer + const $tags = qs($sidebar, `[data-bind="your-tags"]`); + effect(rxjs.merge( + rxjs.of(null), + rxjs.fromEvent(window, "filestash::tag"), + ).pipe( + rxjs.tap(() => ctrlTagPane(createRender($tags), { + tags: [...$tags.querySelectorAll("a")].map(($tag) => $tag.innerText.trim()), + path, + })), + )); + // feature: visibility of the sidebar - const forceRefresh = () => window.dispatchEvent(new Event("resize")); const isVisible = () => settingsGet({ visible: true }, "sidebar").visible; + const forceRefresh = () => window.dispatchEvent(new Event("resize")); effect(rxjs.merge(rxjs.fromEvent(window, "keydown")).pipe( rxjs.filter((e) => e.key === "b" && e.ctrlKey === true), rxjs.tap(() => { @@ -51,36 +69,30 @@ export default async function ctrlSidebar(render, { nRestart = 0 }) { effect(rxjs.merge( rxjs.fromEvent(window, "resize"), rxjs.of(null), - ).pipe(rxjs.tap(() => { - const $breadcrumbButton = qs(document.body, "[alt=\"sidebar-open\"]"); - if (document.body.clientWidth < 1100) $sidebar.classList.add("hidden"); - else if (isVisible()) { - $sidebar.classList.remove("hidden"); - $breadcrumbButton.classList.add("hidden"); - } else { + ).pipe( + rxjs.tap(() => { + const $breadcrumbButton = qs(document.body, "[alt=\"sidebar-open\"]"); + if (document.body.clientWidth < 1100) $sidebar.classList.add("hidden"); + else if (isVisible()) { + $sidebar.classList.remove("hidden"); + $breadcrumbButton.classList.add("hidden"); + } else { + $sidebar.classList.add("hidden"); + $breadcrumbButton.classList.remove("hidden"); + } + }), + rxjs.catchError((err) => { + if (err instanceof DOMException) return rxjs.EMPTY; + throw err; + }), + )); + effect(onClick(qs($sidebar, `img[alt="close"]`)).pipe( + rxjs.tap(() => { + settingsSave({ visible: false }, "sidebar"); $sidebar.classList.add("hidden"); - $breadcrumbButton.classList.remove("hidden"); - } - }), rxjs.catchError((err) => { - if (err instanceof DOMException) return rxjs.EMPTY; - throw err; - }))); - effect(onClick(qs($sidebar, `img[alt="close"]`)).pipe(rxjs.tap(() => { - settingsSave({ visible: false }, "sidebar"); - $sidebar.classList.add("hidden"); - forceRefresh(); - }))); - - // fature: navigation pane - ctrlNavigationPane(render, { $sidebar, nRestart }); - - // feature: tag viewer - effect(rxjs.merge( - rxjs.of(null), - rxjs.fromEvent(window, "filestash::tag"), - ).pipe(rxjs.tap(() => { - ctrlTagPane(createRender(qs($sidebar, `[data-bind="your-tags"]`))); - }))); + forceRefresh(); + }), + )); } const withResize = (function() { @@ -107,62 +119,66 @@ const withResize = (function() { }; }()); -async function ctrlNavigationPane(render, { $sidebar, nRestart }) { - // feature: setup the DOM - const $files = qs($sidebar, `[data-bind="your-files"]`); - if (state.$cache) { - $files.replaceChildren(state.$cache); - $sidebar.firstElementChild.scrollTop = state.scrollTop; - } - onDestroy(() => { - $sidebar.classList.remove("search"); - state.$cache = $files.firstElementChild?.cloneNode(true); - state.scrollTop = $sidebar.firstElementChild.scrollTop; - }); - const chunk = new PathChunk(); - const arr = chunk.toArray(); - const dirpath = chunk.toString(); - const $tree = document.createDocumentFragment(); - for (let i = 0; i { + if (state.$cache) { + $sidebar.replaceChildren(state.$cache); + $sidebar.firstElementChild.scrollTop = state.scrollTop; } - } - $files.replaceChildren($tree); - $sidebar.firstElementChild.scrollTop = state.scrollTop; + onDestroy(() => { + state.$cache = $sidebar.firstElementChild?.cloneNode(true); + state.scrollTop = $sidebar.firstElementChild.scrollTop; + }); + }; +}()); - // feature: smart refresh whenever something happen - const cleaners = []; - cleaners.push(hooks.ls.listen(async({ path }) => { - const $list = await _createListOfFiles(path); - try { - const $ul = qs($sidebar, `[data-path="${path}"] ul`); - $ul.replaceWith($list); - } catch (err) { $files.replaceChildren($list); } - })); - cleaners.push(hooks.mutation.listen(async({ op, path }) => { - if (["mv", "mkdir", "rm"].indexOf(op) === -1) return; - const $list = await _createListOfFiles(path); - try { - const $ul = qs($sidebar, `[data-path="${path}"] ul`); - $ul.replaceWith($list); - } catch (err) {} - })); - onDestroy(() => cleaners.map((fn) => fn())); +async function ctrlNavigationPane(render, { $sidebar, path }) { + // feature: init dom + const $fs = document.createDocumentFragment(); + const dirname = path.replace(new RegExp("[^\/]*$"), ""); + const chunks = dirname.split("/"); + for (let i=1; i { + const cleaners = [ + hooks.ls.listen(({ path }) => subscriber.next(path)), + hooks.mutation.listen(async({ op, path }) => { + if (["mv", "mkdir", "rm"].indexOf(op) === -1) return; + subscriber.next(path); + }), + ]; + return () => cleaners.map((fn) => fn()); + }).pipe( + rxjs.tap(async(path) => { + const display = path === "/" ? render : createRender(qs($sidebar, `[data-path="${path}"] ul`)); + display(await _createListOfFiles(path, {})); + }), + )); // feature: highlight current selection try { - const $active = qs($sidebar, `[data-path="${chunk.toString()}"] a`); + const $active = qs($sidebar, `[data-path="${dirname}"] a`); + const checkVisible = ($el) => { + const rect = $el.getBoundingClientRect(); + return rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth); + }; $active.setAttribute("aria-selected", "true"); - if (checkVisible($active) === false) { + const tags = new URLSearchParams(location.search).getAll("tag").length; + if (checkVisible($active) === false && tags === 0) { $active.offsetTop < window.innerHeight ? $sidebar.firstChild.scrollTo({ top: 0, behavior: "smooth" }) : $active.scrollIntoView({ behavior: "smooth", block: "nearest" }); @@ -186,13 +202,96 @@ async function ctrlNavigationPane(render, { $sidebar, nRestart }) { : $li.classList.remove("hidden"); }); }), + rxjs.finalize(() => $sidebar.classList.remove("search")), )); } -async function _createListOfFiles(path, currentName, dirpath) { +async function ctrlTagPane(render, { tags, path }) { + if (!getConfig("enable_tags", false)) return; + const $page = createElement(` +
+

+ tag + ${t("Tags")} +

+
    +
  • +
+
+ `); + const renderTaglist = createRender(qs($page, `[data-bind="taglist"]`)); + effect(rxjs.merge( + tags.length === 0 ? rxjs.EMPTY : rxjs.of(tags), + ajax({ + url: forwardURLParams(`api/metadata/search`, ["share"]), + method: "POST", + responseType: "json", + body: { + tags: [], + path, + }, + }).pipe( + rxjs.map(({ responseJSON }) => { + const tags = {}; + Object.values(responseJSON.results).forEach((forms) => { + forms.forEach(({ id, value = "" }) => { + if (id !== "tags") return; + value.split(",").forEach((tag) => { + tags[tag.trim()] = null; + }); + }); + }); + return Object.keys(tags).sort(); + }), + rxjs.catchError(() => rxjs.of([])), + ), + ).pipe( + rxjs.distinct((tags) => tags.join(", ")), + rxjs.tap((tags) => { + render($page); + if (tags.length === 0) { + $page.classList.add("hidden"); + return; + } + $page.classList.remove("hidden"); + const $fragment = document.createDocumentFragment(); + tags.forEach((name) => { + const $tag = createElement(` + +
+ + ${name} +
+ + + + +
+ `); + const url = new URL(location.href); + if (url.searchParams.getAll("tag").indexOf(name) === -1) { + $tag.setAttribute("href", forwardURLParams(toHref("/files" + path.replace(new RegExp("[^\/]+$"), "") + "?tag=" + name), ["share", "tag"])); + } else { + url.searchParams.delete("tag", name); + $tag.setAttribute("href", url.toString()); + $tag.setAttribute("aria-selected", "true"); + } + $fragment.appendChild($tag); + }); + renderTaglist($fragment); + }), + )); +} + +const mv = (from, to) => withVirtualLayer( + mv$(from, to), + mvVL(from, to), +); + +async function _createListOfFiles(path, { basename = null, dirname = null }) { const r = await cache().get(path); const whats = r === null - ? (currentName ? [currentName] : []) + ? (basename ? [basename] : []) : r.files .filter(({ type, name }) => type === "directory" && name[0] !== ".") .map(({ name }) => name) @@ -210,11 +309,11 @@ async function _createListOfFiles(path, currentName, dirpath) { directory
${safe(whats[i])}
+
    `); - const $link = qs($li, "a"); - if ($li.getAttribute("data-path") === dirpath && location.pathname.startsWith(toHref("/files/"))) { + if ($link.getAttribute("href") === "/files" + dirname) { $link.removeAttribute("href", ""); $link.removeAttribute("data-link"); } else { @@ -257,112 +356,6 @@ async function _createListOfFiles(path, currentName, dirpath) { return $ul; } -let tagcache = null; -onLogout(() => tagcache = null); -async function ctrlTagPane(render) { - if (!getConfig("enable_tags", false)) return; - render(createElement(`
    ${generateSkeleton(2)}
    `)); - - const $page = createElement(` -
    -

    - tag - ${t("Tags")} -

    -
      -
    • -
    -
    - `); - render($page); - - const path = getCurrentPath("(/view/|/files/)"); - effect(rxjs.merge( - rxjs.of(tagcache).pipe(rxjs.filter((cache) => cache)), - ajax({ - url: forwardURLParams(`api/metadata/search`, ["share"]), - method: "POST", - responseType: "json", - body: JSON.stringify({ - "tags": new URLSearchParams(location.search).getAll("tag"), - path, - }), - }).pipe( - rxjs.map(({ responseJSON }) => - responseJSON.results - .filter(({ type }) => type === "folder") - .map(({ name }) => name) - .sort() - ), - rxjs.tap((tags) => tagcache = tags), - rxjs.catchError(() => rxjs.of([])), - ), - ).pipe(rxjs.tap((tags) => { - if (tags.length === 0) { - $page.classList.add("hidden"); - return; - } - $page.classList.remove("hidden"); - const $fragment = document.createDocumentFragment(); - tags.forEach((name) => { - const $tag = createElement(` - -
    - # - ${name} -
    - - - - -
    - `); - const url = new URL(location.href); - if (url.searchParams.getAll("tag").indexOf(name) === -1) { - $tag.setAttribute("href", forwardURLParams(toHref("/files" + path.replace(new RegExp("[^\/]+$"), "") + "?tag=" + name), ["share", "tag"])); - } else { - url.searchParams.delete("tag", name); - $tag.setAttribute("href", url.toString()); - $tag.setAttribute("aria-selected", "true"); - } - $fragment.appendChild($tag); - }); - qs($page, `[data-bind="taglist"]`).replaceChildren($fragment); - }))); -} - export function init() { return loadCSS(import.meta.url, "./sidebar.css"); } - -function checkVisible($el) { - const rect = $el.getBoundingClientRect(); - return rect.top >= 0 && - rect.left >= 0 && - rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && - rect.right <= (window.innerWidth || document.documentElement.clientWidth); -} - -function shouldDisplay() { - if (new URL(location.toString()).searchParams.get("nav") === "false") return false; - else if (window.self !== window.top) return false; - else if (document.body.clientWidth < 850) return false; - return true; -} - -class PathChunk { - constructor() { - this.pathname = [""].concat(fromHref( - location.pathname.replace(new RegExp("[^/]*$"), "") - ).split("/").slice(2)); - } - - toArray() { - return this.pathname; - } - - toString(i) { - if (i >= 0) return decodeURIComponent(this.pathname.slice(0, i+1).join("/") + "/"); - return decodeURIComponent(this.pathname.join("/")); - } -} diff --git a/public/assets/pages/filespage/ctrl_filesystem.js b/public/assets/pages/filespage/ctrl_filesystem.js index 7b7ebbfe..23648019 100644 --- a/public/assets/pages/filespage/ctrl_filesystem.js +++ b/public/assets/pages/filespage/ctrl_filesystem.js @@ -384,7 +384,7 @@ export default async function(render) { obj.y + obj.h < bounds.y || obj.y > bounds.y + bounds.h ); - if (collision && !checked() || !collision && checked()) { + if ((collision && !checked()) || (!collision && checked())) { $checkbox.click(); } } diff --git a/public/assets/pages/filespage/modal_tag.js b/public/assets/pages/filespage/modal_tag.js index 7ee44eb2..f8138e3f 100644 --- a/public/assets/pages/filespage/modal_tag.js +++ b/public/assets/pages/filespage/modal_tag.js @@ -33,39 +33,51 @@ export default async function(render, { path }) { render($modal); const tags$ = new rxjs.BehaviorSubject(await rxjs.zip( - ajax({ url: forwardURLParams(`api/metadata?path=${path}`, ["share"]), method: "GET", responseType: "json" }).pipe( + ajax({ + url: forwardURLParams(`api/metadata?path=${path}`, ["share"]), + method: "GET", + responseType: "json", + }).pipe( rxjs.map(({ responseJSON }) => responseJSON.results .reduce((acc, { id, value }) => { if (id !== "tags") return acc; - acc = acc.concat(value.split(", ").map( - (name) => ({ name, active: true }) - )); + acc = acc.concat(value.split(", ")); return acc; }, []) ), ), - ajax({ url: forwardURLParams("api/metadata/search", ["share"]), method: "POST", responseType: "json", body: { path: "/" } }).pipe( + shareID ? rxjs.of([]) : ajax({ + url: forwardURLParams("api/metadata/search", ["share"]), + method: "POST", + responseType: "json", + body: { path: "/", tags: [] }, + }).pipe( rxjs.map(({ responseJSON }) => - responseJSON.results - .filter(({ type }) => type === "folder") - .map(({ name }) => ({ name, active: false })) + Object + .values(responseJSON.results) + .reduce((acc, forms) => forms.reduce((facc, { id, value }) => { + if (id !== "tags") return facc; + const vals = value.split(", "); + for (let i=0; i { + const out = currentTags.map((name) => ({ name, active: true })); for (let i=0; i active); - return allTags; + return out; })).toPromise()); const save = (tags) => ajax({ url: forwardURLParams(`api/metadata?path=${path}`, ["share"]), diff --git a/public/assets/pages/filespage/model_files.js b/public/assets/pages/filespage/model_files.js index 1de7aa3a..64637b23 100644 --- a/public/assets/pages/filespage/model_files.js +++ b/public/assets/pages/filespage/model_files.js @@ -11,6 +11,7 @@ import { currentPath } from "./helper.js"; import { setPermissions } from "./model_acl.js"; import fscache from "./cache.js"; import { ls as middlewareLs } from "./model_virtual_layer.js"; +import { tagFilter } from "./model_tag.js"; /* * The naive approach would be to make an API call and refresh the screen after an action @@ -129,6 +130,7 @@ export const ls = (path) => { }), rxjs.tap(({ permissions }) => setPermissions(path, permissions)), middlewareLs(path), + tagFilter(path), ); }; diff --git a/public/assets/pages/filespage/model_tag.js b/public/assets/pages/filespage/model_tag.js new file mode 100644 index 00000000..89f37a78 --- /dev/null +++ b/public/assets/pages/filespage/model_tag.js @@ -0,0 +1,28 @@ +import rxjs from "../../lib/rx.js"; +import { basename, forwardURLParams } from "../../lib/path.js"; +import ajax from "../../lib/ajax.js"; + +export const tagFilter = (path) => rxjs.mergeMap((resp) => { + const tags = new URLSearchParams(location.search).getAll("tag"); + if (tags.length === 0) return rxjs.of(resp); + return ajax({ + url: forwardURLParams(`api/metadata/search`, ["share"]), + body: JSON.stringify({ + "tags": new URLSearchParams(location.search).getAll("tag"), + path, + }), + method: "POST", + responseType: "json", + }).pipe( + rxjs.mergeMap((tags) => rxjs.of(Object.keys(tags.responseJSON.results).map((fullpath) => ({ + name: basename(fullpath.replace(new RegExp("/$"), "")), + type: fullpath.slice(-1) === "/" ? "directory" : "file", + size: -1, + path: fullpath, + })))), + rxjs.map((files) => { + resp.files = files; + return resp; + }), + ); +}); diff --git a/public/assets/pages/filespage/thing.js b/public/assets/pages/filespage/thing.js index c8751ac1..cd91ed96 100644 --- a/public/assets/pages/filespage/thing.js +++ b/public/assets/pages/filespage/thing.js @@ -87,7 +87,7 @@ export function createThing({ const $time = $thing.children[4]; // = qs($thing, ".component_datetime"); $link.setAttribute("href", link); - if (location.search) $link.setAttribute("href", forwardURLParams(link, ["share", "canary", "tag"])); + if (location.search) $link.setAttribute("href", forwardURLParams(link, ["share", "canary"])); $thing.setAttribute("data-droptarget", type === "directory"); $thing.setAttribute("data-selectable", !offline); $thing.setAttribute("data-n", n); diff --git a/public/package.json b/public/package.json index 258fa171..772595bc 100644 --- a/public/package.json +++ b/public/package.json @@ -136,6 +136,12 @@ ], "no-new": [ "off" + ], + "multiline-ternary": [ + "off" + ], + "no-empty-pattern": [ + "off" ] } }, diff --git a/server/common/types.go b/server/common/types.go index cc244219..ea4956d2 100644 --- a/server/common/types.go +++ b/server/common/types.go @@ -75,7 +75,7 @@ const ( type IMetadata interface { Get(ctx *App, path string) ([]FormElement, error) Set(ctx *App, path string, value []FormElement) error - Search(ctx *App, basePath string, facets map[string]any) ([]IFile, error) + Search(ctx *App, path string, facets map[string]any) (map[string][]FormElement, error) } type File struct { diff --git a/server/ctrl/static.go b/server/ctrl/static.go index c1064088..0971184b 100644 --- a/server/ctrl/static.go +++ b/server/ctrl/static.go @@ -349,6 +349,7 @@ func ServeBundle() func(*App, http.ResponseWriter, *http.Request) { "/assets/" + BUILD_REF + "/pages/filespage/state_config.js", "/assets/" + BUILD_REF + "/pages/filespage/helper.js", "/assets/" + BUILD_REF + "/pages/filespage/model_files.js", + "/assets/" + BUILD_REF + "/pages/filespage/model_tag.js", "/assets/" + BUILD_REF + "/pages/filespage/model_virtual_layer.js", "/assets/" + BUILD_REF + "/pages/filespage/modal.css", "/assets/" + BUILD_REF + "/pages/filespage/modal_tag.js",