diff --git a/public/assets/components/decorator_shell_filemanager.js b/public/assets/components/decorator_shell_filemanager.js index f0762acf..4a9611e9 100644 --- a/public/assets/components/decorator_shell_filemanager.js +++ b/public/assets/components/decorator_shell_filemanager.js @@ -32,7 +32,7 @@ export default function(ctrl) { const $main = qs($page, `[data-bind="filemanager-children"]`); $main.classList.remove("hidden"); ctrl(createRender($main)); - ctrlSidebar(createRender(qs($page, `[data-bind="sidebar"]`))); + ctrlSidebar(createRender(qs($page, `[data-bind="sidebar"]`)), {}); onDestroy(async() => { if ((history.state.previous || "").startsWith("/view/") && location.pathname.startsWith("/files/")) { await animate($main, { time: 100, keyframes: slideYOut(20), fill: "none" }); diff --git a/public/assets/components/sidebar.css b/public/assets/components/sidebar.css index b033457e..6884e948 100644 --- a/public/assets/components/sidebar.css +++ b/public/assets/components/sidebar.css @@ -17,6 +17,21 @@ direction: rtl; box-sizing: border-box; } +.component_filemanager_shell .component_sidebar .component_skeleton { + margin-bottom: 5px; + width: calc(100% - 5px); + margin-left: 5px; + opacity: 0; + animation-duration: 0.5s; + animation-delay: 1s; + animation-name: skeleton-appear; + animation-fill-mode: forwards; + animation-iteration-count: 1; +} +@keyframes skeleton-appear { + from { opacity: 0; } + to { opacity: 1; } +} .component_filemanager_shell .component_sidebar > div { direction: ltr; } .component_filemanager_shell .component_sidebar h3 { display: flex; @@ -79,7 +94,8 @@ body.touch-no .component_filemanager_shell .component_sidebar h3 img:hover { width: 100%; box-sizing: border-box; } -.component_filemanager_shell .component_sidebar a:hover, .component_filemanager_shell .component_sidebar a[aria-selected="true"] { +.component_filemanager_shell .component_sidebar [data-bind="your-files"] a:hover, +.component_filemanager_shell .component_sidebar a[aria-selected="true"] { background: var(--border); border-radius: 3px; } @@ -107,3 +123,26 @@ body.touch-no .component_filemanager_shell .component_sidebar h3 img:hover { .component_filemanager_shell li.pointer { cursor: pointer; } + +.component_filemanager_shell .component_sidebar [data-bind="taglist"] a { + margin-bottom: 2px; + justify-content: space-between; + font-size: 0.95rem; +} +.component_filemanager_shell .component_sidebar [data-bind="taglist"] a[aria-selected="true"] { + background: var(--light); + color: #f2f2f2; + box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); +} +.component_filemanager_shell .component_sidebar [data-bind="taglist"] a[aria-selected="true"] svg { + display: block; +} +.component_filemanager_shell .component_sidebar [data-bind="taglist"] a svg { + display: none; + background: rgba(255, 255, 255, 0.03); + align-self: center; + width: 13px; + height: 13px; + border-radius: 50%; + padding: 4px; +} diff --git a/public/assets/components/sidebar.js b/public/assets/components/sidebar.js index 52d3994a..3117c700 100644 --- a/public/assets/components/sidebar.js +++ b/public/assets/components/sidebar.js @@ -1,16 +1,20 @@ import { createElement, createRender, onDestroy } from "../lib/skeleton/index.js"; import rxjs, { effect, onClick } from "../lib/rx.js"; +import ajax from "../lib/ajax.js"; import assert from "../lib/assert.js"; import { fromHref, 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"; +import { get as getConfig } from "../model/config.js"; import { loadCSS } from "../helpers/loader.js"; import t from "../locales/index.js"; import cache from "../pages/filespage/cache.js"; import { hooks, mv as mv$ } from "../pages/filespage/model_files.js"; import { extractPath, isDir, isNativeFileUpload } from "../pages/filespage/helper.js"; import { mv as mvVL, withVirtualLayer } from "../pages/filespage/model_virtual_layer.js"; +import { getCurrentPath } from "../pages/viewerpage/common.js"; +import { generateSkeleton } from "./skeleton.js"; const state = { scrollTop: 0, $cache: null }; const mv = (from, to) => withVirtualLayer( @@ -18,7 +22,7 @@ const mv = (from, to) => withVirtualLayer( mvVL(from, to), ); -export default async function ctrlSidebar(render, nRestart = 0) { +export default async function ctrlSidebar(render, { nRestart = 0 }) { if (!shouldDisplay()) return; const $sidebar = render(createElement(` @@ -28,11 +32,6 @@ export default async function ctrlSidebar(render, nRestart = 0) {
- -

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

`)); @@ -76,7 +75,12 @@ export default async function ctrlSidebar(render, nRestart = 0) { ctrlNavigationPane(render, { $sidebar, nRestart }); // feature: tag viewer - ctrlTagPane(createRender(qs($sidebar, `[data-bind="your-tags"]`))); + effect(rxjs.merge( + rxjs.of(null), + rxjs.fromEvent(window, "filestash::tag"), + ).pipe(rxjs.tap(() => { + ctrlTagPane(createRender(qs($sidebar, `[data-bind="your-tags"]`))); + }))); } const withResize = (function() { @@ -254,37 +258,63 @@ async function _createListOfFiles(path, currentName, dirpath) { } async function ctrlTagPane(render) { - const $page = createElement(` - - `); - render($page); + if (!getConfig("enable_tags", false)) return; + render(createElement(`
${generateSkeleton(2)}
`)); - // only enable this pane in canary mode until it's actually ready - if (new URLSearchParams(location.search).get("canary") !== "true") { - $page.classList.add("hidden"); - const orFail = (something) => assert.type(something, HTMLElement); - orFail(orFail($page.parentElement).previousElementSibling).classList.add("hidden"); + const $page = createElement(` +
+

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

+ +
+ `); + const tags = await ajax({ + url: forwardURLParams(`api/metadata/search`, ["share"]), + method: "POST", + responseType: "json", + body: JSON.stringify({ + "tags": [], + "path": getCurrentPath("(/view/|/files/)"), + }), + }).pipe( + rxjs.map(({ responseJSON }) => + responseJSON.results + .filter(({type}) => type === "folder") + .map(({ name }) => name) + .sort() + ), + rxjs.catchError(() => rxjs.of([])), + ).toPromise(); + if (tags.length === 0) { + render(createElement("
")); return; } + render($page); - const tags = [ - { name: t("Bookmark"), color: "green" }, - { name: "important", color: "red" }, - { name: "foobar", color: "saddlebrown" }, - ]; - const $tmpl = (name, color) => createElement(` - - - - -
${name}
-
- `); const $fragment = document.createDocumentFragment(); - tags.forEach(({ name, color }) => { - $fragment.appendChild($tmpl(name, color)); + 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(getCurrentPath() + "?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"]`).appendChild($fragment); } diff --git a/public/assets/lib/path.js b/public/assets/lib/path.js index 930cb3d7..403fbed3 100644 --- a/public/assets/lib/path.js +++ b/public/assets/lib/path.js @@ -12,10 +12,11 @@ export function join(baseURL, segment) { } export function forwardURLParams(url, allowed = []) { - const _url = new URL(window.location.origin + "/" + url); + const link = new URL(window.location.origin + "/" + url); for (const [key, value] of new URLSearchParams(location.search)) { if (allowed.indexOf(key) < 0) continue; - _url.searchParams.set(key, value); + else if (link.searchParams.getAll(key).indexOf(value) !== -1) continue; + link.searchParams.append(key, value); } - return _url.pathname.substring(1) + _url.search; + return link.pathname.substring(1) + link.search; } diff --git a/public/assets/pages/filespage/ctrl_submenu.js b/public/assets/pages/filespage/ctrl_submenu.js index 34da7f24..151fba2d 100644 --- a/public/assets/pages/filespage/ctrl_submenu.js +++ b/public/assets/pages/filespage/ctrl_submenu.js @@ -119,7 +119,7 @@ function componentLeft(render, { $scroll, getSelectionLength$ }) { - `))), diff --git a/public/assets/pages/filespage/modal_tag.css b/public/assets/pages/filespage/modal_tag.css index 93f9c887..7a4c4028 100644 --- a/public/assets/pages/filespage/modal_tag.css +++ b/public/assets/pages/filespage/modal_tag.css @@ -1,14 +1,10 @@ .component_tag { font-size: 1rem; } -.component_tag [data-bind="taglist"] { - display: flex; - gap: 5px; - flex-wrap: wrap; - margin: -10px; - padding: 20px 10px 10px 10px; +.component_tag form { + margin-bottom: 15px; } -.component_tag input { +.component_tag form input { font-size: 1rem; border: 2px solid var(--border); border-radius: 5px; @@ -18,9 +14,20 @@ display: block; box-sizing: border-box; } -.component_tag input::placeholder { +.component_tag form input::placeholder { color: rgba(0,0,0,0.2); } +.component_tag [data-bind="taglist"] { + display: flex; + gap: 5px; + flex-wrap: wrap; + margin: -3px 0 0px 0px; + padding: 0; +} +.component_tag [data-bind="taglist"] .component_skeleton { + margin: 0; + height: 29px; +} .component_tag .tag { display: flex; background: #f2f2f2; @@ -31,6 +38,7 @@ font-size: 0.95rem; cursor: pointer; letter-spacing: -0.5px; + text-transform: lowercase; } .component_tag .tag.active { background: var(--dark); diff --git a/public/assets/pages/filespage/modal_tag.js b/public/assets/pages/filespage/modal_tag.js index e14eabc1..fd570f33 100644 --- a/public/assets/pages/filespage/modal_tag.js +++ b/public/assets/pages/filespage/modal_tag.js @@ -1,40 +1,147 @@ import { createElement } from "../../lib/skeleton/index.js"; +import rxjs, { effect, onClick } from "../../lib/rx.js"; +import { forwardURLParams } from "../../lib/path.js"; +import ajax from "../../lib/ajax.js"; import { qs } from "../../lib/dom.js"; import assert from "../../lib/assert.js"; +import { generateSkeleton } from "../../components/skeleton.js"; import t from "../../locales/index.js"; + +const shareID = new URLSearchParams(location.search).get("share"); + const $tmpl = createElement(`
Projects
- +
`); -export default function(render, { path }) { - const tags = [ - { name: "Bookmark", active: true }, - { name: "Projects" }, - { name: "Important" }, - ]; - +export default async function(render, { path }) { const $modal = createElement(`
-
- + +
-
+
+ ${generateSkeleton(1)} +
`); - const $fragment = document.createDocumentFragment(); - tags.forEach(({ name, active }) => { - const $el = assert.type($tmpl, HTMLElement).cloneNode(true); - $el.firstElementChild.innerText = name; - if (active) $el.classList.add("active"); - $fragment.appendChild($el); - }); - qs($modal, `[data-bind="taglist"]`).appendChild($fragment); - render($modal, ({ id }) => { console.log(`QUIT id=${id} path=${path}`); }); + render($modal); + + const tags$ = new rxjs.BehaviorSubject(await rxjs.zip( + 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 }) + )); + return acc; + }, []) + ), + ), + ajax({ url: forwardURLParams("api/metadata/search", ["share"]), method: "POST", responseType: "json", body: { path: "/" }}).pipe( + rxjs.map(({ responseJSON }) => + responseJSON.results + .filter(({ type, name }) => type === "folder") + .map(({ name }) => ({ name, active: false })) + ), + ), + ).pipe(rxjs.map(([currentTags, allTags]) => { + for (let i=0; i active); + return allTags; + })).toPromise()); + const save = (tags) => ajax({ + url: forwardURLParams(`api/metadata?path=${path}`, ["share"]), + method: "POST", + body: tags.length === 0 ? [] : [{ + id: "tags", + type: "hidden", + value: tags.join(", "), + }], + }).pipe(rxjs.tap(() => window.dispatchEvent(new Event("filestash::tag")))); + + // feature: create DOM + const dom$ = tags$.pipe( + rxjs.map((tags) => tags.sort((a, b) => a.name > b.name ? 1 : -1)), + rxjs.map((tags) => tags.map(({ name, active }) => { + const $el = assert.type($tmpl, HTMLElement).cloneNode(true); + $el.firstElementChild.innerText = name; + if (active) $el.classList.add("active"); + qs($el, "svg").onclick = (e) => { + if (shareID) return; + e.preventDefault(); + e.stopPropagation(); + $el.classList.remove("active"); + tags$.next(tags$.value.filter((tag) => { + return tag.name !== $el.innerText.trim(); + })); + save(tags$.value + .filter(({ active }) => active) + .map(({ name }) => name)).toPromise(); + } + return $el; + })), + rxjs.tap(($nodes) => { + const $container = qs($modal, `[data-bind="taglist"]`); + if ($nodes.length === 0) $container.replaceChildren(createElement(`
`)); + else $container.replaceChildren(...$nodes); + }), + ); + + // feature: tag creation + effect(rxjs.fromEvent(qs($modal, "form"), "submit").pipe( + rxjs.filter(() => !shareID), + rxjs.tap((e) => { + e.preventDefault(); + const tagname = new FormData(e.target).get("tag").toLowerCase().trim(); + if (!tagname) return; + else if (tags$.value.find(({ name }) => name === tagname)) return; + qs($modal, `input[name="tag"]`).value = ""; + tags$.next(tags$.value.concat({ + name: tagname, + active: true, + })); + }), + rxjs.mergeMap(() => save( + tags$.value + .filter(({ active }) => !!active) + .map(({ name }) => name) + )), + )); + + // feature: toggle tags + effect(dom$.pipe( + rxjs.mergeMap(($nodes) => rxjs.merge( + ...$nodes.map(($node) => onClick($node)), + ).pipe( + rxjs.filter(() => !shareID), + rxjs.tap(($node) => $node.classList.toggle("active")), + rxjs.debounceTime(800), + rxjs.map(() => + $nodes + .filter(($node) => $node.classList.contains("active")) + .map(($node) => $node.innerText.trim()) + .filter((text) => !!text) + ), + )), + rxjs.mergeMap((tags) => save(tags)), + )); } diff --git a/public/assets/pages/viewerpage/common.js b/public/assets/pages/viewerpage/common.js index 49ee10f0..2842583b 100644 --- a/public/assets/pages/viewerpage/common.js +++ b/public/assets/pages/viewerpage/common.js @@ -14,7 +14,7 @@ export function getDownloadUrl() { return forwardURLParams("api/files/cat?path=" + encodeURIComponent(getCurrentPath()), ["share"]); } -export function getCurrentPath() { +export function getCurrentPath(start = "/view/") { const fullpath = fromHref(location.pathname + location.hash); - return decodeURIComponent(fullpath.replace(new RegExp("^/view"), "")); + return decodeURIComponent(fullpath.replace(new RegExp("^" + start), "/")); } diff --git a/server/common/config.go b/server/common/config.go index c0d67c9c..855a037f 100644 --- a/server/common/config.go +++ b/server/common/config.go @@ -358,7 +358,6 @@ func (this *Configuration) Export() interface{} { Name string `json:"name"` UploadButton bool `json:"upload_button"` Connections interface{} `json:"connections"` - EnableShare bool `json:"enable_share"` SharedLinkDefaultAccess string `json:"share_default_access"` SharedLinkRedirect string `json:"share_redirect"` Logout string `json:"logout"` @@ -370,9 +369,11 @@ func (this *Configuration) Export() interface{} { FilePageDefaultView string `json:"default_view"` AuthMiddleware []string `json:"auth"` Thumbnailer []string `json:"thumbnailer"` - EnableChromecast bool `json:"enable_chromecast"` Origin string `json:"origin"` Version string `json:"version"` + EnableChromecast bool `json:"enable_chromecast"` + EnableShare bool `json:"enable_share"` + EnableTags bool `json:"enable_tags"` }{ Editor: this.Get("general.editor").String(), ForkButton: this.Get("general.fork_button").Bool(), @@ -380,7 +381,6 @@ func (this *Configuration) Export() interface{} { Name: this.Get("general.name").String(), UploadButton: this.Get("general.upload_button").Bool(), Connections: this.Conn, - EnableShare: this.Get("features.share.enable").Bool(), SharedLinkDefaultAccess: this.Get("features.share.default_access").String(), SharedLinkRedirect: this.Get("features.share.redirect").String(), Logout: this.Get("general.logout").String(), @@ -408,7 +408,6 @@ func (this *Configuration) Export() interface{} { } return tArray }(), - EnableChromecast: this.Get("features.protection.enable_chromecast").Bool(), Origin: func() string { host := this.Get("general.host").String() if host == "" { @@ -420,7 +419,12 @@ func (this *Configuration) Export() interface{} { } return scheme + host }(), - Version: BUILD_REF, + Version: BUILD_REF, + EnableChromecast: this.Get("features.protection.enable_chromecast").Bool(), + EnableShare: this.Get("features.share.enable").Bool(), + EnableTags: func() bool { + return Hooks.Get.Metadata() != nil + }(), } } diff --git a/server/common/plugin.go b/server/common/plugin.go index 591df918..2023b5aa 100644 --- a/server/common/plugin.go +++ b/server/common/plugin.go @@ -254,6 +254,16 @@ func (this Get) StaticPatch() [][]byte { return staticOverrides } +var meta IMetadata + +func (this Register) Metadata(m IMetadata) { + meta = m +} + +func (this Get) Metadata() IMetadata { + return meta +} + func init() { Hooks.Register.FrontendOverrides(OverrideVideoSourceMapper) } diff --git a/server/common/types.go b/server/common/types.go index 104bcbd7..cc244219 100644 --- a/server/common/types.go +++ b/server/common/types.go @@ -66,6 +66,18 @@ type AuditQueryResult struct { RenderHTML string `json:"render"` } +const ( + MetaModeTag = 1 << iota + MetaModeBookmark + MetaModeForm +) + +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) +} + type File struct { FName string `json:"name"` FType string `json:"type"` diff --git a/server/ctrl/metadata.go b/server/ctrl/metadata.go new file mode 100644 index 00000000..b6b680f5 --- /dev/null +++ b/server/ctrl/metadata.go @@ -0,0 +1,76 @@ +package ctrl + +import ( + "encoding/json" + "fmt" + "net/http" + + . "github.com/mickael-kerjean/filestash/server/common" +) + +func MetaGet(ctx *App, w http.ResponseWriter, r *http.Request) { + m := Hooks.Get.Metadata() + if m == nil { + SendErrorResult(w, ErrNotImplemented) + return + } + path, err := PathBuilder(ctx, r.URL.Query().Get("path")) + if err != nil { + SendErrorResult(w, err) + return + } + out, err := m.Get(ctx, path) + if err != nil { + SendErrorResult(w, err) + return + } + SendSuccessResults(w, out) +} + +func MetaUpsert(ctx *App, w http.ResponseWriter, r *http.Request) { + m := Hooks.Get.Metadata() + if m == nil { + SendErrorResult(w, ErrNotImplemented) + return + } + path, err := PathBuilder(ctx, r.URL.Query().Get("path")) + if err != nil { + SendErrorResult(w, err) + return + } + forms := []FormElement{} + if err := json.NewDecoder(r.Body).Decode(&forms); err != nil { + SendErrorResult(w, ErrNotImplemented) + return + } + if err := m.Set(ctx, path, forms); err != nil { + SendErrorResult(w, err) + return + } + SendSuccessResult(w, nil) +} + +func MetaSearch(ctx *App, w http.ResponseWriter, r *http.Request) { + m := Hooks.Get.Metadata() + if m == nil { + SendErrorResult(w, ErrNotImplemented) + return + } + facets := map[string]any{} + if err := json.NewDecoder(r.Body).Decode(&facets); err != nil { + SendErrorResult(w, ErrNotImplemented) + return + } + path, err := PathBuilder(ctx, fmt.Sprintf("%s", facets["path"])) + if err != nil { + SendErrorResult(w, err) + return + } + delete(facets, "path") + out, err := m.Search(ctx, path, facets) + if err != nil { + SendErrorResult(w, err) + return + } + SendSuccessResults(w, out) +} diff --git a/server/routes.go b/server/routes.go index ff57cee2..6f3464c5 100644 --- a/server/routes.go +++ b/server/routes.go @@ -73,6 +73,12 @@ func Build(r *mux.Router, a App) { middlewares = []Middleware{ApiHeaders, SecureHeaders, SecureOrigin, BodyParser, CanManageShare, PluginInjector} share.HandleFunc("/{share}", NewMiddlewareChain(ShareUpsert, middlewares, a)).Methods("POST") + meta := r.PathPrefix(WithBase("/api/metadata")).Subrouter() + middlewares = []Middleware{ApiHeaders, SecureHeaders, SecureOrigin, WithPublicAPI, SessionStart, LoggedInOnly, PluginInjector} + meta.HandleFunc("", NewMiddlewareChain(MetaGet, middlewares, a)).Methods("GET") + meta.HandleFunc("", NewMiddlewareChain(MetaUpsert, middlewares, a)).Methods("POST") + meta.HandleFunc("/search", NewMiddlewareChain(MetaSearch, middlewares, a)).Methods("POST") + // Webdav server / Shared Link middlewares = []Middleware{IndexHeaders, SecureHeaders, PluginInjector} r.HandleFunc(WithBase("/s/{share}"), NewMiddlewareChain(ServeFrontofficeHandler, middlewares, a)).Methods("GET")