mirror of
				https://github.com/mickael-kerjean/filestash.git
				synced 2025-11-01 02:43:35 +08:00 
			
		
		
		
	feature (tag): support for tagging
This commit is contained in:
		| @ -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" }); | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
|  | ||||
| @ -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) { | ||||
|                 <input type="text" placeholder="${t("Your Files")}" /> | ||||
|             </h3> | ||||
|             <div data-bind="your-files"></div> | ||||
|  | ||||
|             <h3> | ||||
|                 <img src="data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgd2lkdGg9IjE2IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPg0KICAgIDxwYXRoIHN0eWxlPSJmaWxsOiAjNTc1OTVhOyIgZD0iTTEgNy43NzVWMi43NUMxIDEuNzg0IDEuNzg0IDEgMi43NSAxaDUuMDI1Yy40NjQgMCAuOTEuMTg0IDEuMjM4LjUxM2w2LjI1IDYuMjVhMS43NSAxLjc1IDAgMCAxIDAgMi40NzRsLTUuMDI2IDUuMDI2YTEuNzUgMS43NSAwIDAgMS0yLjQ3NCAwbC02LjI1LTYuMjVBMS43NTIgMS43NTIgMCAwIDEgMSA3Ljc3NVptMS41IDBjMCAuMDY2LjAyNi4xMy4wNzMuMTc3bDYuMjUgNi4yNWEuMjUuMjUgMCAwIDAgLjM1NCAwbDUuMDI1LTUuMDI1YS4yNS4yNSAwIDAgMCAwLS4zNTRsLTYuMjUtNi4yNWEuMjUuMjUgMCAwIDAtLjE3Ny0uMDczSDIuNzVhLjI1LjI1IDAgMCAwLS4yNS4yNVpNNiA1YTEgMSAwIDEgMSAwIDIgMSAxIDAgMCAxIDAtMloiPjwvcGF0aD4NCjwvc3ZnPg0K" alt="tag"> | ||||
|                 ${t("Tags")} | ||||
|             </h3> | ||||
|             <div data-bind="your-tags"></div> | ||||
|         </div> | ||||
|     `)); | ||||
| @ -76,7 +75,12 @@ export default async function ctrlSidebar(render, nRestart = 0) { | ||||
|     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"]`))); | ||||
|     }))); | ||||
| } | ||||
|  | ||||
| const withResize = (function() { | ||||
| @ -254,37 +258,63 @@ async function _createListOfFiles(path, currentName, dirpath) { | ||||
| } | ||||
|  | ||||
| async function ctrlTagPane(render) { | ||||
|     if (!getConfig("enable_tags", false)) return; | ||||
|     render(createElement(`<div>${generateSkeleton(2)}</div>`)); | ||||
|  | ||||
|     const $page = createElement(` | ||||
|         <div> | ||||
|             <h3> | ||||
|                 <img src="data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjE2IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgd2lkdGg9IjE2IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPg0KICAgIDxwYXRoIHN0eWxlPSJmaWxsOiAjNTc1OTVhOyIgZD0iTTEgNy43NzVWMi43NUMxIDEuNzg0IDEuNzg0IDEgMi43NSAxaDUuMDI1Yy40NjQgMCAuOTEuMTg0IDEuMjM4LjUxM2w2LjI1IDYuMjVhMS43NSAxLjc1IDAgMCAxIDAgMi40NzRsLTUuMDI2IDUuMDI2YTEuNzUgMS43NSAwIDAgMS0yLjQ3NCAwbC02LjI1LTYuMjVBMS43NTIgMS43NTIgMCAwIDEgMSA3Ljc3NVptMS41IDBjMCAuMDY2LjAyNi4xMy4wNzMuMTc3bDYuMjUgNi4yNWEuMjUuMjUgMCAwIDAgLjM1NCAwbDUuMDI1LTUuMDI1YS4yNS4yNSAwIDAgMCAwLS4zNTRsLTYuMjUtNi4yNWEuMjUuMjUgMCAwIDAtLjE3Ny0uMDczSDIuNzVhLjI1LjI1IDAgMCAwLS4yNS4yNVpNNiA1YTEgMSAwIDEgMSAwIDIgMSAxIDAgMCAxIDAtMloiPjwvcGF0aD4NCjwvc3ZnPg0K" alt="tag"> | ||||
|                 ${t("Tags")} | ||||
|             </h3> | ||||
|             <ul> | ||||
|                 <li data-bind="taglist"></li> | ||||
|             </ul> | ||||
|         </div> | ||||
|     `); | ||||
|     render($page); | ||||
|  | ||||
|     // 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 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("<div></div>")); | ||||
|         return; | ||||
|     } | ||||
|     render($page); | ||||
|  | ||||
|     const tags = [ | ||||
|         { name: t("Bookmark"), color: "green" }, | ||||
|         { name: "important", color: "red" }, | ||||
|         { name: "foobar", color: "saddlebrown" }, | ||||
|     ]; | ||||
|     const $tmpl = (name, color) => createElement(` | ||||
|         <a data-link href="/tags/${name}/?canary=true" draggable="false"> | ||||
|                 <svg class="component_icon" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"> | ||||
|                     <circle cx="50" cy="50" r="50" style="opacity: 0.25; fill: ${color};" /> | ||||
|                 </svg> | ||||
|     const $fragment = document.createDocumentFragment(); | ||||
|     tags.forEach((name) => { | ||||
|         const $tag = createElement(` | ||||
|             <a data-link draggable="false" class="no-select"> | ||||
|                 <div class="ellipsis">${name}</div> | ||||
|                 <svg class="component_icon" draggable="false" alt="close" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||||
|                     <line x1="18" y1="6" x2="6" y2="18"></line> | ||||
|                     <line x1="6" y1="6" x2="18" y2="18"></line> | ||||
|                 </svg> | ||||
|             </a> | ||||
|         `); | ||||
|     const $fragment = document.createDocumentFragment(); | ||||
|     tags.forEach(({ name, color }) => { | ||||
|         $fragment.appendChild($tmpl(name, color)); | ||||
|         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); | ||||
| } | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
|  | ||||
| @ -119,7 +119,7 @@ function componentLeft(render, { $scroll, getSelectionLength$ }) { | ||||
|             <button data-action="share" title="${t("Share")}" class="${(getConfig("enable_share") && !new URLSearchParams(location.search).has("share")) ? "" : "hidden"}"> | ||||
|                 ${t("Share")} | ||||
|             </button> | ||||
|             <button data-action="tag" title="${t("Tag")}" class="${new URLSearchParams(location.search).get("canary") === "true" ? "" : "hidden"}" tabindex="-1"> | ||||
|             <button data-action="tag" title="${t("Tag")}" class="${getConfig("enable_tags", false) ? "" : "hidden"}"> | ||||
|                 ${t("Tag")} | ||||
|             </button> | ||||
|         `))), | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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(` | ||||
|     <div class="tag no-select"> | ||||
|         <div class="ellipsis">Projects</div> | ||||
|         <svg class="component_icon" draggable="false" alt="close" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||||
|         <svg style="${shareID ? "opacity:0.2" : ""}" class="component_icon" draggable="false" alt="close" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | ||||
|             <line x1="18" y1="6" x2="6" y2="18"></line> | ||||
|             <line x1="6" y1="6" x2="18" y2="18"></line> | ||||
|         </svg> | ||||
|     </div> | ||||
| `); | ||||
|  | ||||
| export default function(render, { path }) { | ||||
|     const tags = [ | ||||
|         { name: "Bookmark", active: true }, | ||||
|         { name: "Projects" }, | ||||
|         { name: "Important" }, | ||||
|     ]; | ||||
|  | ||||
| export default async function(render, { path }) { | ||||
|     const $modal = createElement(` | ||||
|         <div class="component_tag"> | ||||
|             <form> | ||||
|                 <input type="text" placeholder="${t("Create a Tag")}" value=""> | ||||
|             <form class="${shareID ? "hidden" : ""}"> | ||||
|                 <input name="tag" type="text" placeholder="${t("Add a Tag")}" value=""> | ||||
|             </form> | ||||
|             <div class="scroll-y" data-bind="taglist"></div> | ||||
|             <div class="scroll-y" data-bind="taglist"> | ||||
|                 ${generateSkeleton(1)} | ||||
|             </div> | ||||
|         </div> | ||||
|     `); | ||||
|     const $fragment = document.createDocumentFragment(); | ||||
|     tags.forEach(({ name, active }) => { | ||||
|     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<allTags.length; i++) { | ||||
|             for (let j=0; j<currentTags.length; j++) { | ||||
|                 if (currentTags[j].name === allTags[i].name) { | ||||
|                     allTags[i].active = true; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         if (!shareID && allTags.length === 0) { | ||||
|             return [{ name: t("bookmark"), active: false }]; | ||||
|         } | ||||
|         if (shareID) return allTags.filter(({ active }) => 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"); | ||||
|         $fragment.appendChild($el); | ||||
|     }); | ||||
|     qs($modal, `[data-bind="taglist"]`).appendChild($fragment); | ||||
|     render($modal, ({ id }) => { console.log(`QUIT id=${id} path=${path}`); }); | ||||
|             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(`<div class="center full-width">∅</div>`)); | ||||
|             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)), | ||||
|     )); | ||||
| } | ||||
|  | ||||
| @ -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), "/")); | ||||
| } | ||||
|  | ||||
| @ -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 == "" { | ||||
| @ -421,6 +420,11 @@ func (this *Configuration) Export() interface{} { | ||||
| 			return scheme + host | ||||
| 		}(), | ||||
| 		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 | ||||
| 		}(), | ||||
| 	} | ||||
| } | ||||
|  | ||||
|  | ||||
| @ -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) | ||||
| } | ||||
|  | ||||
| @ -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"` | ||||
|  | ||||
							
								
								
									
										76
									
								
								server/ctrl/metadata.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								server/ctrl/metadata.go
									
									
									
									
									
										Normal file
									
								
							| @ -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) | ||||
| } | ||||
| @ -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") | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 MickaelK
					MickaelK