diff --git a/webui/package-lock.json b/webui/package-lock.json index af8a92b..1023681 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -13,7 +13,9 @@ "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", "@solidjs/router": "^0.15.4", + "dompurify": "^3.2.0", "fuse.js": "^7.1.0", + "marked": "^13.0.0", "solid-js": "^1.9.5" }, "devDependencies": { @@ -56,6 +58,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -439,7 +442,8 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", - "license": "(Apache-2.0 AND BSD-3-Clause)" + "license": "(Apache-2.0 AND BSD-3-Clause)", + "peer": true }, "node_modules/@bufbuild/protoc-gen-es": { "version": "2.11.0", @@ -497,6 +501,7 @@ "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz", "integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } @@ -1414,6 +1419,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript/vfs": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", @@ -1506,6 +1518,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1572,6 +1585,15 @@ } } }, + "node_modules/dompurify": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -1759,6 +1781,18 @@ "yallist": "^3.0.2" } }, + "node_modules/marked": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-13.0.3.tgz", + "integrity": "sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/merge-anything": { "version": "5.1.7", "resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-5.1.7.tgz", @@ -1834,6 +1868,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1946,6 +1981,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -1967,6 +2003,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz", "integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", @@ -2021,6 +2058,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2066,6 +2104,7 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/webui/package.json b/webui/package.json index 8ecb077..4a91dbc 100644 --- a/webui/package.json +++ b/webui/package.json @@ -25,7 +25,9 @@ "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", "@solidjs/router": "^0.15.4", + "dompurify": "^3.2.0", "fuse.js": "^7.1.0", + "marked": "^13.0.0", "solid-js": "^1.9.5" } } diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 46523e3..3095e79 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -13,6 +13,7 @@ import { ChangeServerPasswordPage } from './page/ChangeServerPasswordPage' import { SettingsPage } from './page/SettingsPage' import { ServerSearchPage } from './page/ServerSearchPage' import { ServerProfilePage } from './page/ServerProfilePage' +import { ServerMdPreviewPage } from './page/ServerMdPreviewPage' import { UpdatePage } from './page/UpdatePage' import { TransfersPage } from './page/TransfersPage' @@ -66,6 +67,11 @@ const App: Component = () => { component: ServerProfilePage, }, + { + path: '/server/:uuid/md/:username/*path', + component: ServerMdPreviewPage, + }, + { path: makeBrowsePath(':uuid', ':username', '*path'), component: ServerBrowsePage, diff --git a/webui/src/page/ServerBrowsePage.tsx b/webui/src/page/ServerBrowsePage.tsx index d5e659e..42d3fa8 100644 --- a/webui/src/page/ServerBrowsePage.tsx +++ b/webui/src/page/ServerBrowsePage.tsx @@ -4,16 +4,24 @@ import styles from './ServerBrowsePage.module.css' import { useFileServerUrl, useGlobalState, useRpcClient } from '../ctx' import { ConnectError } from '@connectrpc/connect' -import { A, useLocation, useParams } from '@solidjs/router' +import { + A, + useLocation, + useNavigate, + useParams, + useSearchParams, +} from '@solidjs/router' import { FileMeta } from '../../pb/clientrpc/v1/rpc_pb' import { makeBrowsePath, makeFileUrl, + makeMdPreviewPath, normalizePath, trimStrEllipsis, } from '../util' import { FileTable } from '../FileTable' import { QueueButton } from '../QueueButton' +import { getAutoOpenReadme } from '../uiPrefs' const Page: Component = () => { const { @@ -25,6 +33,8 @@ const Page: Component = () => { const state = useGlobalState() const client = useRpcClient() const fsUrl = useFileServerUrl() + const navigate = useNavigate() + const [searchParams] = useSearchParams<{ noauto?: string }>() const server = state.getServerByUuid(uuid) if (!server) { @@ -44,6 +54,9 @@ const Page: Component = () => { try { setLoading(true) + const shouldAutoOpen = + searchParams.noauto !== '1' && getAutoOpenReadme() + const stream = client.getDirFiles({ serverUuid: server.uuid, username: username, @@ -51,6 +64,23 @@ const Page: Component = () => { }) for await (const msg of stream) { + if (shouldAutoOpen) { + const readme = msg.content.find( + (f) => + !f.isDir && + f.name.toLowerCase() === 'readme.md', + ) + if (readme) { + const pth = path === '/' ? '' : path + const readmePath = pth + '/' + readme.name + navigate( + makeMdPreviewPath(uuid, username, readmePath), + { replace: true }, + ) + return + } + } + const res = [...files(), ...msg.content] res.sort((a, b) => { if (a.isDir && !b.isDir) { @@ -161,24 +191,42 @@ const Page: Component = () => { filePath, ) + const lowerName = item.meta.name.toLowerCase() + const isMarkdown = + lowerName.endsWith('.md') || + lowerName.endsWith('.markdown') + + const actions = ( + <> + + 🔗 + + + + ) + + if (isMarkdown) { + return { + href: makeMdPreviewPath( + uuid, + username, + filePath, + ), + actions, + } + } + return { - actions: ( - <> - - 🔗 - - - - ), + actions, onClick: () => { state.previewFile(uuid, username, filePath) }, diff --git a/webui/src/page/ServerMdPreviewPage.module.css b/webui/src/page/ServerMdPreviewPage.module.css new file mode 100644 index 0000000..2ca24f7 --- /dev/null +++ b/webui/src/page/ServerMdPreviewPage.module.css @@ -0,0 +1,176 @@ +.container { + padding: 1rem; + width: 100%; +} + +.location { + min-height: 4rem; + border-bottom: 0.2rem solid rgba(0, 0, 0, 0.5); + margin-bottom: 1rem; +} +.segment { + display: inline-block; + margin: 0.25rem; + background-color: rgba(0, 0, 0, 0.25); + padding: 0.5rem; + font-weight: bold; + cursor: default; + text-decoration: none; +} +a.segment { + cursor: pointer; +} + +.actions { + margin-bottom: 1rem; + text-align: right; +} +.closeButton { + cursor: pointer; + font-size: 0.9rem; +} + +.body { + max-width: 50rem; + margin: 0 auto; + padding: 0 1rem; +} + +.content { + line-height: 1.55; + word-break: break-word; +} + +.content h1, +.content h2, +.content h3, +.content h4, +.content h5, +.content h6 { + margin-top: 1.5rem; + margin-bottom: 0.5rem; + line-height: 1.2; +} +.content h1 { + font-size: 1.8rem; + border-bottom: 0.1rem solid rgba(255, 255, 255, 0.15); + padding-bottom: 0.3rem; +} +.content h2 { + font-size: 1.5rem; + border-bottom: 0.1rem solid rgba(255, 255, 255, 0.1); + padding-bottom: 0.2rem; +} +.content h3 { + font-size: 1.25rem; +} +.content h4 { + font-size: 1.1rem; +} + +.content p { + margin: 0.75rem 0; +} + +.content ul, +.content ol { + padding-left: 1.5rem; + margin: 0.75rem 0; +} +.content li { + margin: 0.25rem 0; +} + +.content blockquote { + margin: 0.75rem 0; + padding: 0.25rem 0.75rem; + border-left: 0.25rem solid rgba(255, 255, 255, 0.25); + color: rgba(255, 255, 255, 0.75); + font-style: italic; +} + +.content code { + background-color: rgba(0, 0, 0, 0.35); + padding: 0.05rem 0.3rem; + border-radius: 0.2rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9em; +} +.content pre { + background-color: rgba(0, 0, 0, 0.35); + padding: 0.75rem; + border-radius: 0.3rem; + overflow-x: auto; + margin: 0.75rem 0; +} +.content pre code { + background: none; + padding: 0; + font-size: 0.9em; +} + +/* Syntax highlighting (e.g. Prism, highlight.js) can be layered on later + by targeting .content pre code. */ + +.content table { + border-collapse: collapse; + margin: 0.75rem 0; + width: 100%; +} +.content th, +.content td { + border: 0.05rem solid rgba(255, 255, 255, 0.2); + padding: 0.35rem 0.6rem; + text-align: left; +} +.content th { + background-color: rgba(0, 0, 0, 0.25); +} + +.content a { + color: #6cb6ff; + text-decoration: underline; +} +.content a[data-blocked='true'] { + color: rgba(255, 255, 255, 0.5); + cursor: not-allowed; + text-decoration: line-through; +} + +.content img { + max-width: 100%; + height: auto; + display: block; + margin: 0.5rem 0; +} +.content img[data-blocked='true'] { + display: none; +} + +.content hr { + border: none; + border-top: 0.05rem solid rgba(255, 255, 255, 0.15); + margin: 1.5rem 0; +} + +.raw { + white-space: pre-wrap; + word-break: break-word; + background-color: rgba(0, 0, 0, 0.35); + padding: 1rem; + border-radius: 0.3rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9rem; +} + +.footer { + margin-top: 2rem; + padding-top: 1rem; + border-top: 0.05rem solid rgba(255, 255, 255, 0.1); + text-align: right; + font-size: 0.9rem; + opacity: 0.7; +} +.footer a { + cursor: pointer; +} diff --git a/webui/src/page/ServerMdPreviewPage.tsx b/webui/src/page/ServerMdPreviewPage.tsx new file mode 100644 index 0000000..04e07ab --- /dev/null +++ b/webui/src/page/ServerMdPreviewPage.tsx @@ -0,0 +1,492 @@ +import styles from './ServerMdPreviewPage.module.css' +import stylesCommon from '../common.module.css' + +import { + Component, + createEffect, + createResource, + createSignal, + For, + Match, + Show, + Switch, +} from 'solid-js' +import { A, useLocation, useNavigate, useParams } from '@solidjs/router' +import { marked } from 'marked' +import DOMPurify from 'dompurify' + +import { useFileServerUrl, useGlobalState } from '../ctx' +import { + makeBrowsePath, + makeFileUrl, + makeMdPreviewPath, + normalizePath, + trimStrEllipsis, +} from '../util' +import { QueueButton } from '../QueueButton' + +const MAX_SIZE_BYTES = 1024 * 1024 // 1 MiB + +const PURIFY_CONFIG = { + ALLOWED_TAGS: [ + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'code', + 'pre', + 'strong', + 'em', + 's', + 'del', + 'blockquote', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'a', + 'img', + 'hr', + 'br', + 'div', + 'span', + 'input', + ], + ALLOWED_ATTR: [ + 'href', + 'target', + 'rel', + 'src', + 'alt', + 'title', + 'class', + 'id', + 'checked', + 'disabled', + 'type', + 'colspan', + 'rowspan', + ], + FORBID_TAGS: [ + 'script', + 'iframe', + 'object', + 'embed', + 'style', + 'meta', + 'form', + 'button', + ], + ALLOW_DATA_ATTR: false, +} + +type FetchResult = + | { kind: 'ok'; markdown: string } + | { kind: 'too-large'; size: number } + | { kind: 'error'; message: string } + +async function fetchMarkdown(url: string): Promise { + try { + const head = await fetch(url, { method: 'HEAD' }) + if (head.status !== 200) { + return { + kind: 'error', + message: `server returned status ${head.status}`, + } + } + + const lenHeader = head.headers.get('Content-Length') + if (lenHeader !== null) { + const size = Number.parseInt(lenHeader, 10) + if (Number.isFinite(size) && size > MAX_SIZE_BYTES) { + return { kind: 'too-large', size } + } + } + + const res = await fetch(url) + if (res.status !== 200) { + return { + kind: 'error', + message: `server returned status ${res.status}`, + } + } + + return { kind: 'ok', markdown: await res.text() } + } catch (err) { + console.error('failed to fetch markdown:', url, err) + return { + kind: 'error', + message: 'failed to fetch, check browser console', + } + } +} + +type RewriteDeps = { + serverUuid: string + username: string + shareName: string + dir: string + fsUrl: string + onMdLinkClick: (resolvedPath: string, ev: MouseEvent) => void +} + +function rewriteDom(root: HTMLElement, deps: RewriteDeps) { + const { serverUuid, username, shareName, dir, fsUrl, onMdLinkClick } = deps + + const inShare = (segments: string[]) => + segments.length > 0 && segments[0] === shareName + + const blockLink = (link: HTMLAnchorElement, reason: string) => { + link.setAttribute('href', '#') + link.setAttribute('data-blocked', 'true') + link.setAttribute('title', reason) + } + + const blockImg = (img: HTMLImageElement, reason: string) => { + img.removeAttribute('src') + img.setAttribute('data-blocked', 'true') + img.setAttribute('alt', img.getAttribute('alt') ?? '[blocked image]') + img.setAttribute('title', reason) + } + + for (const link of Array.from(root.querySelectorAll('a'))) { + const href = link.getAttribute('href') + if (!href) { + continue + } + + if (href.startsWith('#')) { + continue + } + + if (/^https?:\/\//i.test(href)) { + link.setAttribute('target', '_blank') + link.setAttribute('rel', 'noopener noreferrer') + if (!link.hasAttribute('title')) { + link.setAttribute( + 'title', + 'Middle-click or right-click to open', + ) + } + link.addEventListener('click', (ev) => { + if (ev.button !== 0) return + if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) { + return + } + ev.preventDefault() + }) + continue + } + + if (/^[a-z][a-z0-9+.-]*:/i.test(href)) { + blockLink(link, 'only http(s) external links are allowed') + continue + } + + if (href.startsWith('/') || href.includes('\\')) { + blockLink(link, 'absolute paths are not allowed') + continue + } + + const resolved = normalizePath(dir + '/' + href) + if (!inShare(resolved.segments)) { + blockLink(link, 'link target escapes the current share') + continue + } + + const lower = href.toLowerCase() + const last = resolved.segments[resolved.segments.length - 1] ?? '' + const lastLower = last.toLowerCase() + const isMd = + lastLower.endsWith('.md') || + lastLower.endsWith('.markdown') || + lower.endsWith('.md') || + lower.endsWith('.markdown') + + if (isMd) { + const mdHref = makeMdPreviewPath(serverUuid, username, resolved.path) + link.setAttribute('href', mdHref) + link.addEventListener('click', (ev) => + onMdLinkClick(resolved.path, ev), + ) + continue + } + + link.setAttribute( + 'href', + makeFileUrl(fsUrl, serverUuid, username, resolved.path, { + allowCache: true, + }), + ) + link.setAttribute('target', '_blank') + link.setAttribute('rel', 'noopener noreferrer') + } + + for (const img of Array.from(root.querySelectorAll('img'))) { + const src = img.getAttribute('src') + if (!src) { + continue + } + + if (src.startsWith('data:image/')) { + continue + } + + if (/^[a-z][a-z0-9+.-]*:/i.test(src)) { + blockImg(img, 'external images are not allowed') + continue + } + + if (src.startsWith('/') || src.includes('\\')) { + blockImg(img, 'absolute paths are not allowed') + continue + } + + const resolved = normalizePath(dir + '/' + src) + if (!inShare(resolved.segments)) { + blockImg(img, 'image source escapes the current share') + continue + } + + img.setAttribute( + 'src', + makeFileUrl(fsUrl, serverUuid, username, resolved.path, { + allowCache: true, + }), + ) + } +} + +const Page: Component = () => { + const { + uuid, + username, + path: pathRaw, + } = useParams<{ uuid: string; username: string; path: string }>() + + const state = useGlobalState() + const fsUrl = useFileServerUrl() + const navigate = useNavigate() + + const server = state.getServerByUuid(uuid) + if (!server) { + return

No such server "{uuid}"

+ } + + const { path, segments: pathSegments } = normalizePath( + decodeURIComponent(pathRaw), + ) + + if (pathSegments.length === 0) { + return ( +
+
+ No file specified. +
+
+ ) + } + + const shareName = pathSegments[0] + const dir = + pathSegments.length > 1 + ? '/' + pathSegments.slice(0, -1).join('/') + : '/' + + const url = makeFileUrl(fsUrl, uuid, username, path) + + const [viewAsRaw, setViewAsRaw] = createSignal(false) + const [result] = createResource(() => url, fetchMarkdown) + + let contentRef: HTMLDivElement | undefined + + createEffect(() => { + const res = result() + if (!contentRef) { + return + } + if (!res || res.kind !== 'ok') { + contentRef.replaceChildren() + return + } + if (viewAsRaw()) { + return + } + + const html = marked.parse(res.markdown, { gfm: true }) as string + const clean = DOMPurify.sanitize(html, PURIFY_CONFIG) as string + + const scratch = document.createElement('div') + scratch.innerHTML = clean + + rewriteDom(scratch, { + serverUuid: uuid, + username, + shareName, + dir, + fsUrl, + onMdLinkClick: (resolvedPath, ev) => { + ev.preventDefault() + navigate(makeMdPreviewPath(uuid, username, resolvedPath)) + }, + }) + + contentRef.replaceChildren(...Array.from(scratch.childNodes)) + }) + + return ( +
+
+
🖧 {server.name()}
+ + 👤 {username} + + + {(seg, i) => { + const isLast = i() === pathSegments.length - 1 + if (isLast) { + return ( + + 📄 {trimStrEllipsis(seg, 30)} + + ) + } + return ( + + {trimStrEllipsis(seg, 20)} + + ) + }} + +
+ +
+ +
+ + + + } + > +
+								{result()?.kind === 'ok'
+									? (result() as { markdown: string })
+											.markdown
+									: ''}
+							
+
+ +
+ { + e.preventDefault() + setViewAsRaw(true) + }} + > + 📜 View raw + + } + > + { + e.preventDefault() + setViewAsRaw(false) + }} + > + 👁️ View rendered + + +
+
+ } + > + +
+ Loading... +
+
+ +
+
+ {(result() as { message: string }).message} +
+
+
+ +
+

+ File too large to preview ( + {(result() as { size: number }).size} bytes, limit{' '} + {MAX_SIZE_BYTES} bytes). +

+

+ + Download + +

+

+ + 🔗 Open in Browser + +

+
+
+ + + ) +} + +export const ServerMdPreviewPage: Component = () => { + const loc = useLocation() + return ( + + + + ) +} diff --git a/webui/src/page/SettingsPage.tsx b/webui/src/page/SettingsPage.tsx index 89e942e..67d93f6 100644 --- a/webui/src/page/SettingsPage.tsx +++ b/webui/src/page/SettingsPage.tsx @@ -3,6 +3,7 @@ import { Component, createSignal, For, onMount, Show } from 'solid-js' import stylesCommon from '../common.module.css' import { ConnectError } from '@connectrpc/connect' import { useRpcClient } from '../ctx' +import { getAutoOpenReadme, setAutoOpenReadme } from '../uiPrefs' const P2pSettings: Component = () => { const client = useRpcClient() @@ -543,6 +544,54 @@ const TransferSettings: Component = () => { ) } +const BrowsingSettings: Component = () => { + const [autoOpenReadme, setAutoOpenReadmeSig] = createSignal( + getAutoOpenReadme(), + ) + + const onToggle = (e: Event) => { + const checked = (e.currentTarget as HTMLInputElement).checked + setAutoOpenReadmeSig(checked) + setAutoOpenReadme(checked) + } + + return ( +
+

Browsing

+ +

+ These settings control how the share browser behaves. They are + saved in your browser and apply only to this client. +

+ +
+ +
e.preventDefault()}> + + + + + + + +
+ + + +
+
+
+ ) +} + export const SettingsPage: Component = () => { return (
{ +
) } diff --git a/webui/src/uiPrefs.ts b/webui/src/uiPrefs.ts new file mode 100644 index 0000000..74b87e6 --- /dev/null +++ b/webui/src/uiPrefs.ts @@ -0,0 +1,13 @@ +const KEY_AUTO_OPEN_README = 'friendnet.autoOpenReadme' + +export function getAutoOpenReadme(): boolean { + return localStorage.getItem(KEY_AUTO_OPEN_README) === '1' +} + +export function setAutoOpenReadme(enabled: boolean): void { + if (enabled) { + localStorage.setItem(KEY_AUTO_OPEN_README, '1') + } else { + localStorage.removeItem(KEY_AUTO_OPEN_README) + } +} diff --git a/webui/src/util.ts b/webui/src/util.ts index 90eac1c..e34bf14 100644 --- a/webui/src/util.ts +++ b/webui/src/util.ts @@ -203,6 +203,15 @@ export function makeBrowsePath( return `/server/${serverUuid}/browse/${username}${escapePathSegments(normPath)}` } +export function makeMdPreviewPath( + serverUuid: string, + username: string, + path: string, +): string { + const { path: normPath } = normalizePath(path) + return `/server/${serverUuid}/md/${username}${escapePathSegments(normPath)}` +} + export function trimStrEllipsis(str: string, len: number): string { if (str.length <= len) { return str