diff --git a/src/client/components/SidebarArticles.tsx b/src/client/components/SidebarArticles.tsx index eacabb44..a6ae6736 100644 --- a/src/client/components/SidebarArticles.tsx +++ b/src/client/components/SidebarArticles.tsx @@ -11,6 +11,7 @@ import { Weight, } from "../lib/variables"; import { ItemViewModel } from "../../lib/view-models/items"; +import { fileTreeFromItemViewModels } from "../lib/fileTree"; interface Props { items: ItemViewModel[]; @@ -18,6 +19,12 @@ interface Props { articleState: "Draft" | "Public" | "Private"; } +export type TreeNode = { + name: string; + items: ItemViewModel[]; + children: { [name: string]: TreeNode }; +}; + export const SortType = { ByUpdatedAt: 1, Alphabetically: 2, @@ -59,26 +66,83 @@ export const SidebarArticles = ({ items, sortType, articleState }: Props) => { localStorage.setItem(StorageName[articleState], isDetailsOpen.toString()); }, [isDetailsOpen]); + // build recursive tree from item.parent (segments array) + const fileTree = fileTreeFromItemViewModels(items); + const topLevelItems = fileTree.items; + const roots: { [name: string]: TreeNode } = fileTree.children; + + 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]; + const isNested = path.includes("/"); + return ( +
  • +
    + + {node.name} + + {countSubtreeItems(node)} + + + +
    +
  • + ); + }; + return (
    {ArticleState[articleState]} {items.length} -
    ); @@ -95,6 +159,31 @@ const articleDetailsStyle = css({ }, }); +const articleDetailsListStyle = css({ + listStyle: "none", + margin: 0, + paddingLeft: getSpace(1), +}); + +const articleDetailsNestedStyle = css({ + position: "relative", + paddingLeft: getSpace(3), + + "&:before": { + content: "''", + position: "absolute", + left: getSpace(3), + top: 0, + bottom: 0, + width: 1, + backgroundColor: Colors.gray20, + }, +}); + +const articleDetailsNestedItemStyle = css({ + paddingLeft: getSpace(3 / 2), //小数点は無効なので12pxだとこの様な形になります 1 = 8px +}); + const articleSummaryStyle = css({ alignItems: "center", backgroundColor: "transparent", @@ -137,9 +226,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/client/lib/fileTree.ts b/src/client/lib/fileTree.ts new file mode 100644 index 00000000..348a4206 --- /dev/null +++ b/src/client/lib/fileTree.ts @@ -0,0 +1,48 @@ +import { ItemViewModel } from "../../lib/view-models/items"; +import type { TreeNode } from "../components/SidebarArticles"; + +export type FileTreeNodeMap = { [name: string]: TreeNode }; +export type FileTreeNode = { + name: string; + items: ItemViewModel[]; + children: FileTreeNodeMap; +}; + +export const fileTreeFromItemViewModels = ( + items: ItemViewModel[], +): FileTreeNode => { + const topLevelItems: ItemViewModel[] = []; + const roots: FileTreeNodeMap = {}; + + 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); + } + }); + + return { + name: "root", + items: topLevelItems, + children: roots, + }; +}; diff --git a/src/lib/entities/qiita-item.ts b/src/lib/entities/qiita-item.ts index 27e2e067..c809534c 100644 --- a/src/lib/entities/qiita-item.ts +++ b/src/lib/entities/qiita-item.ts @@ -1,3 +1,5 @@ +import { sep } from "path"; + export class QiitaItem { public readonly id: string | null; public readonly title: string; @@ -64,4 +66,8 @@ export class QiitaItem { this.slide = slide; this.ignorePublish = ignorePublish; } + + getParentDirNames() { + return this.name.split(sep).slice(0, -1); + } } diff --git a/src/lib/file-system-repo.ts b/src/lib/file-system-repo.ts index 1fb27427..194368df 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 215d15d9..e049c854 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 d35699b6..85f9c312 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.getParentDirNames(), }; if (item.id) { diff --git a/src/server/app.ts b/src/server/app.ts index c1ea01e9..c134dfa4 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -66,7 +66,7 @@ export function startLocalChangeWatcher({ }) { const wsServer = new WebSocketServer({ server }); const watcher = chokidar.watch(watchPath, { - ignored: ["**/.remote/**"], + ignored: [/node_modules|\.git/, "**/.remote/**"], }); watcher.on("change", () => { wsServer.clients.forEach((client) => {