diff --git a/package.json b/package.json index 0c45fdb..3186f5f 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@types/jest": "^30.0.0", "@types/node": "^22.15.30", "@types/react": "^19.1.13", - "@types/react-dom": "^19.1.6", + "@types/react-dom": "^19.1.13", "@types/webpack": "^5.28.5", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^7.0.0", @@ -43,8 +43,8 @@ "lint-staged": "^15.5.1", "npm-run-all": "^4.1.5", "prettier": "^3.6.2", - "react": "^19.1.1", - "react-dom": "^19.1.0", + "react": "^19.1.13", + "react-dom": "^19.1.13", "react-router": "^7.8.2", "ts-jest": "^29.4.4", "ts-loader": "^9.5.4", diff --git a/src/client/components/SidebarArticles.tsx b/src/client/components/SidebarArticles.tsx index eacabb4..69585e0 100644 --- a/src/client/components/SidebarArticles.tsx +++ b/src/client/components/SidebarArticles.tsx @@ -59,6 +59,84 @@ export const SidebarArticles = ({ items, sortType, articleState }: Props) => { localStorage.setItem(StorageName[articleState], isDetailsOpen.toString()); }, [isDetailsOpen]); + // build recursive tree from item.parent (segments array) + const topLevelItems: ItemViewModel[] = []; + + type TreeNode = { + name: string; + items: ItemViewModel[]; + children: { [name: string]: TreeNode }; + }; + + const roots: { [name: string]: TreeNode } = {}; + + const addToTree = (segments: string[], item: ItemViewModel) => { + const rootName = segments[0]; + if (!roots[rootName]) + roots[rootName] = { name: rootName, items: [], children: {} }; + let node = roots[rootName]; + const rest = segments.slice(1); + if (rest.length === 0) { + node.items.push(item); + return; + } + for (const seg of rest) { + if (!node.children[seg]) + node.children[seg] = { name: seg, items: [], children: {} }; + node = node.children[seg]; + } + node.items.push(item); + }; + + items.forEach((item) => { + if (!item.parent || item.parent.length === 0) { + topLevelItems.push(item); + } else { + addToTree(item.parent, item); + } + }); + + const countSubtreeItems = (node: TreeNode): number => + node.items.length + + Object.values(node.children).reduce((s, c) => s + countSubtreeItems(c), 0); + + const renderNode = (node: TreeNode, path: string) => { + const cmp = compare[sortType]; + return ( +
  • +
    + + {node.name} + + {countSubtreeItems(node)} + + + +
    +
  • + ); + }; + return (
    @@ -66,19 +144,26 @@ export const SidebarArticles = ({ items, sortType, articleState }: Props) => { {items.length}
    ); @@ -93,6 +178,44 @@ const articleDetailsStyle = css({ "&[open] > summary::before": { content: "'expand_more'", }, + // nested lists: draw vertical guide lines inside the padded area + "& ul": { + listStyle: "none", + margin: 0, + paddingLeft: getSpace(1), + }, + "& ul ul": { + position: "relative", + paddingLeft: getSpace(3), + }, + "& ul ul::before": { + content: "''", + position: "absolute", + left: getSpace(3), + top: 0, + bottom: 0, + width: 1, + backgroundColor: Colors.gray20, + }, + "& ul ul > li": { + paddingLeft: getSpace(1.5), + }, + "& ul ul ul": { + position: "relative", + paddingLeft: getSpace(4), + }, + "& ul ul ul::before": { + content: "''", + position: "absolute", + left: getSpace(3), + top: 0, + bottom: 0, + width: 1, + backgroundColor: Colors.gray20, + }, + "& ul ul ul > li": { + paddingLeft: getSpace(1.5), + }, }); const articleSummaryStyle = css({ @@ -137,9 +260,9 @@ const articlesListItemStyle = css({ fontSize: Typography.body2, gap: getSpace(1), lineHeight: LineHeight.bodyDense, - padding: `${getSpace(3 / 4)}px ${getSpace(5 / 2)}px ${getSpace( - 3 / 4, - )}px ${getSpace(3 / 2)}px`, + padding: `${getSpace(3 / 4)}px ${getSpace(5 / 2)}px ${getSpace(3 / 4)}px ${getSpace( + 3, + )}px`, whiteSpace: "nowrap", textOverflow: "ellipsis", diff --git a/src/lib/file-system-repo.ts b/src/lib/file-system-repo.ts index 337d574..e96a3b5 100644 --- a/src/lib/file-system-repo.ts +++ b/src/lib/file-system-repo.ts @@ -206,7 +206,7 @@ export class FileSystemRepo { } private parseFilename(filename: string) { - return path.basename(filename, ".md"); + return filename.replace(/\.md$/, ""); } private getFilePath(uuid: string, remote: boolean = false) { @@ -214,9 +214,14 @@ export class FileSystemRepo { } private async getItemFilenames(remote: boolean = false) { - return await fs.readdir( - this.getRootOrRemotePath(remote), - FileSystemRepo.fileSystemOptions(), + return ( + await fs.readdir( + this.getRootOrRemotePath(remote), + FileSystemRepo.fileSystemOptions(), + ) + ).filter( + (itemFilename) => + /\.md$/.test(itemFilename) && !itemFilename.startsWith(".remote/"), ); } @@ -246,6 +251,8 @@ export class FileSystemRepo { private static fileSystemOptions() { return { encoding: "utf8", + withFileTypes: false, + recursive: true, } as const; } @@ -325,12 +332,10 @@ export class FileSystemRepo { async loadItems(): Promise { const itemFilenames = await this.getItemFilenames(); - const promises = itemFilenames - .filter((itemFilename) => /\.md$/.test(itemFilename)) - .map(async (itemFilename) => { - const basename = this.parseFilename(itemFilename); - return await this.loadItemByBasename(basename); - }); + const promises = itemFilenames.map(async (itemFilename) => { + const basename = this.parseFilename(itemFilename); + return await this.loadItemByBasename(basename); + }); const items = excludeNull(await Promise.all(promises)); return items; diff --git a/src/lib/view-models/items.ts b/src/lib/view-models/items.ts index 215d15d..e049c85 100644 --- a/src/lib/view-models/items.ts +++ b/src/lib/view-models/items.ts @@ -5,6 +5,7 @@ export type ItemViewModel = { title: string; updated_at: string; modified: boolean; + parent: string[]; }; export type ItemsIndexViewModel = { diff --git a/src/server/api/items.ts b/src/server/api/items.ts index d35699b..99836bb 100644 --- a/src/server/api/items.ts +++ b/src/server/api/items.ts @@ -27,6 +27,7 @@ const itemsIndex = async (req: Express.Request, res: Express.Response) => { title: item.title, updated_at: item.updatedAt, modified: item.modified, + parent: item.name.split("/").slice(0, -1), }; if (item.id) { diff --git a/src/server/app.ts b/src/server/app.ts index def04db..ac41f76 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -65,7 +65,10 @@ export function startLocalChangeWatcher({ watchPath: string; }) { const wsServer = new WebSocketServer({ server }); - const watcher = chokidar.watch(watchPath); + const watcher = chokidar.watch(watchPath, { + ignored: /node_modules|\.git/, + persistent: true, + }); watcher.on("change", () => { wsServer.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { diff --git a/yarn.lock b/yarn.lock index 5dde63f..e740514 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1315,10 +1315,10 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/react-dom@^19.1.6": - version "19.1.6" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.1.6.tgz#4af629da0e9f9c0f506fc4d1caa610399c595d64" - integrity sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw== +"@types/react-dom@^19.1.13": + version "19.2.3" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" + integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== "@types/react@^19.1.13": version "19.1.13" @@ -5919,12 +5919,12 @@ raw-body@^3.0.0: iconv-lite "0.6.3" unpipe "1.0.0" -react-dom@^19.1.0: - version "19.1.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.1.0.tgz#133558deca37fa1d682708df8904b25186793623" - integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g== +react-dom@^19.1.13: + version "19.2.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.0.tgz#00ed1e959c365e9a9d48f8918377465466ec3af8" + integrity sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ== dependencies: - scheduler "^0.26.0" + scheduler "^0.27.0" react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" @@ -5944,10 +5944,10 @@ react-router@^7.8.2: cookie "^1.0.1" set-cookie-parser "^2.6.0" -react@^19.1.1: - version "19.1.1" - resolved "https://registry.yarnpkg.com/react/-/react-19.1.1.tgz#06d9149ec5e083a67f9a1e39ce97b06a03b644af" - integrity sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ== +react@^19.1.13: + version "19.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5" + integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ== read-pkg@^3.0.0: version "3.0.0" @@ -6214,10 +6214,10 @@ safe-regex-test@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -scheduler@^0.26.0: - version "0.26.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.26.0.tgz#4ce8a8c2a2095f13ea11bf9a445be50c555d6337" - integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA== +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== schema-utils@^4.3.0, schema-utils@^4.3.2: version "4.3.2"