diff --git a/l10n/bundle.l10n.de.json b/l10n/bundle.l10n.de.json index 7b42c77e..5a2aa153 100644 --- a/l10n/bundle.l10n.de.json +++ b/l10n/bundle.l10n.de.json @@ -109,6 +109,7 @@ "dashboard.header.tabs.taxonomies": "Taxonomien", "dashboard.header.viewSwitch.toGrid": "Zur Rasteransicht wechseln", "dashboard.header.viewSwitch.toList": "Zur Listenansicht wechseln", + "dashboard.header.viewSwitch.toStructure": "Zur Strukturansicht wechseln", "dashboard.layout.sponsor.support.msg": "Unterstützen Sie Front Matter", "dashboard.layout.sponsor.review.label": "Bewerten", "dashboard.layout.sponsor.review.msg": "Bewerten Sie Front Matter", diff --git a/l10n/bundle.l10n.fr.json b/l10n/bundle.l10n.fr.json index 46e9acf7..12945e84 100644 --- a/l10n/bundle.l10n.fr.json +++ b/l10n/bundle.l10n.fr.json @@ -109,6 +109,7 @@ "dashboard.header.tabs.taxonomies": "Taxonomies", "dashboard.header.viewSwitch.toGrid": "Afficher en grille", "dashboard.header.viewSwitch.toList": "Afficher en liste", + "dashboard.header.viewSwitch.toStructure": "Afficher en structure", "dashboard.layout.sponsor.support.msg": "Soutenir Front Matter", "dashboard.layout.sponsor.review.label": "Donnez votre avis", "dashboard.layout.sponsor.review.msg": "Donnez votre avis sur Front Matter", diff --git a/l10n/bundle.l10n.ja.json b/l10n/bundle.l10n.ja.json index a931e9fe..17f2312b 100644 --- a/l10n/bundle.l10n.ja.json +++ b/l10n/bundle.l10n.ja.json @@ -214,6 +214,7 @@ "dashboard.header.viewSwitch.toGrid": "グリッド表示", "dashboard.header.viewSwitch.toList": "リスト表示", + "dashboard.header.viewSwitch.toStructure": "構造表示", "dashboard.layout.sponsor.support.msg": "Front Matterをサポートする", "dashboard.layout.sponsor.review.label": "評価する", diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index e6eaf97c..90499486 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -222,6 +222,7 @@ "dashboard.header.viewSwitch.toGrid": "Change to grid", "dashboard.header.viewSwitch.toList": "Change to list", + "dashboard.header.viewSwitch.toStructure": "Change to structure", "dashboard.layout.sponsor.support.msg": "Support Front Matter", "dashboard.layout.sponsor.review.label": "Review", diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 81472430..0c87ecca 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -222,6 +222,7 @@ "dashboard.header.viewSwitch.toGrid": "切换到网格视图", "dashboard.header.viewSwitch.toList": "切换到列表视图", + "dashboard.header.viewSwitch.toStructure": "切换到结构视图", "dashboard.layout.sponsor.support.msg": "支持 Front Matter", "dashboard.layout.sponsor.review.label": "评价", diff --git a/src/dashboardWebView/components/Contents/List.tsx b/src/dashboardWebView/components/Contents/List.tsx index 1808b443..de011dc7 100644 --- a/src/dashboardWebView/components/Contents/List.tsx +++ b/src/dashboardWebView/components/Contents/List.tsx @@ -17,6 +17,8 @@ export const List: React.FunctionComponent = ({ className = `grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-5 gap-4`; } else if (view === DashboardViewType.List) { className = `-mx-4`; + } else if (view === DashboardViewType.Structure) { + className = `structure-view`; } return ( diff --git a/src/dashboardWebView/components/Contents/Overview.tsx b/src/dashboardWebView/components/Contents/Overview.tsx index ddeb8955..7e7d4c2f 100644 --- a/src/dashboardWebView/components/Contents/Overview.tsx +++ b/src/dashboardWebView/components/Contents/Overview.tsx @@ -9,6 +9,7 @@ import { GroupOption } from '../../constants/GroupOption'; import { GroupingSelector, PageAtom, PagedItems, ViewSelector } from '../../state'; import { Item } from './Item'; import { List } from './List'; +import { StructureView } from './StructureView'; import usePagination from '../../hooks/usePagination'; import { LocalizationKey, localize } from '../../../localization'; import { PinnedItemsAtom } from '../../state/atom/PinnedItems'; @@ -145,16 +146,21 @@ export const Overview: React.FunctionComponent = ({ /> {settings && settings?.contentFolders?.length > 0 ? (

{localize(LocalizationKey.dashboardContentsOverviewNoMarkdown)}

- + ) : (

{localize(LocalizationKey.dashboardContentsOverviewNoFolders)}

- + )} ); } + // Handle Structure view first - it overrides all other display modes + if (view === DashboardViewType.Structure) { + return ; + } + if (grouping !== GroupOption.none) { return ( <> @@ -196,7 +202,7 @@ export const Overview: React.FunctionComponent = ({

{localize(LocalizationKey.dashboardContentsOverviewPinned)} - +

{pinnedPages.map((page, idx) => ( diff --git a/src/dashboardWebView/components/Contents/StructureItem.tsx b/src/dashboardWebView/components/Contents/StructureItem.tsx new file mode 100644 index 00000000..3deb5225 --- /dev/null +++ b/src/dashboardWebView/components/Contents/StructureItem.tsx @@ -0,0 +1,79 @@ +import { useRecoilValue } from 'recoil'; +import { MarkdownIcon } from '../../../panelWebView/components/Icons/MarkdownIcon'; +import { Page } from '../../models/Page'; +import { SettingsSelector } from '../../state'; +import { DateField } from '../Common/DateField'; +import { ContentActions } from './ContentActions'; +import { useMemo } from 'react'; +import { Status } from './Status'; +import * as React from 'react'; +import * as l10n from '@vscode/l10n'; +import { LocalizationKey } from '../../../localization'; +import useCard from '../../hooks/useCard'; +import { ItemSelection } from '../Common/ItemSelection'; +import { openFile } from '../../utils'; +import useSelectedItems from '../../hooks/useSelectedItems'; +import { cn } from '../../../utils/cn'; + +export interface IStructureItemProps extends Page { } + +export const StructureItem: React.FunctionComponent = ({ + ...pageData +}: React.PropsWithChildren) => { + const { selectedFiles } = useSelectedItems(); + const settings = useRecoilValue(SettingsSelector); + const draftField = useMemo(() => settings?.draftField, [settings]); + const { escapedTitle } = useCard(pageData, settings?.dashboardState?.contents?.cardFields); + + const isSelected = useMemo(() => selectedFiles.includes(pageData.fmFilePath), [selectedFiles, pageData.fmFilePath]); + + const onOpenFile = React.useCallback(() => { + openFile(pageData.fmFilePath); + }, [pageData.fmFilePath]); + + return ( +
+
+ + + + + + +
+ {pageData.date && ( + + )} + + {draftField && draftField.name && typeof pageData[draftField.name] !== "undefined" && ( + + )} + + +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/Contents/StructureView.tsx b/src/dashboardWebView/components/Contents/StructureView.tsx new file mode 100644 index 00000000..b73cc15c --- /dev/null +++ b/src/dashboardWebView/components/Contents/StructureView.tsx @@ -0,0 +1,217 @@ +import { Disclosure } from '@headlessui/react'; +import { ChevronRightIcon, FolderIcon } from '@heroicons/react/24/solid'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { Page } from '../../models'; +import { StructureItem } from './StructureItem'; +import { parseWinPath } from '../../../helpers/parseWinPath'; + +export interface IStructureViewProps { + pages: Page[]; +} + +interface FolderNode { + name: string; + path: string; + children: FolderNode[]; + pages: Page[]; +} + +export const StructureView: React.FunctionComponent = ({ + pages +}: React.PropsWithChildren) => { + const folderTree = useMemo(() => { + const root: FolderNode = { + name: '', + path: '', + children: [], + pages: [] + }; + + const folderMap = new Map(); + folderMap.set('', root); + + // Helper to compute the normalized folder path for a page. + // It ensures the page's folder starts with the `fmFolder` segment and + // preserves any subpaths after that segment (so subfolders are created). + const computeNormalizedFolderPath = (page: Page): string => { + if (!page.fmFolder) { + return ''; + } + + const fmFolder = page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + + // If we have a file path, use its directory (exclude the filename) to compute + // the relative path. This avoids treating filenames as folder segments. + const filePath = page.fmFilePath ? parseWinPath(page.fmFilePath).replace(/^\/+|\/+$/g, '') : ''; + const fileDir = filePath && filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')).replace(/^\/+|\/+$/g, '') : ''; + + if (fileDir) { + // If the content folder is known, and the file directory starts with it, + // replace that root with the fmFolder (preserving subfolders after it). + if (page.fmPageFolder?.path) { + const contentFolderPath = parseWinPath(page.fmPageFolder.path).replace(/^\/+|\/+$/g, ''); + if (fileDir.startsWith(contentFolderPath)) { + const rel = fileDir.substring(contentFolderPath.length).replace(/^\/+|\/+$/g, ''); + return rel ? `${fmFolder}/${rel}` : fmFolder; + } + } + + // Otherwise try to find fmFolder as a directory segment in the fileDir + const segments = fileDir.split('/').filter(Boolean); + const fmIndex = segments.indexOf(fmFolder); + if (fmIndex >= 0) { + return segments.slice(fmIndex).join('/'); + } + } + + // Fallback: just use the fmFolder name + return fmFolder; + }; + + // First pass: create all folder nodes (ensure nodes exist even if a page lacks fmFilePath) + for (const page of pages) { + if (!page.fmFolder) { + continue; + } + + const normalizedPath = computeNormalizedFolderPath(page).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + if (!normalizedPath) { + continue; + } + + const parts = normalizedPath.split('/').filter(part => part.length > 0); + + let currentPath = ''; + let currentNode = root; + + for (const part of parts) { + const fullPath = currentPath ? `${currentPath}/${part}` : part; + + if (!folderMap.has(fullPath)) { + const newNode: FolderNode = { + name: part, + path: fullPath, + children: [], + pages: [] + }; + folderMap.set(fullPath, newNode); + currentNode.children.push(newNode); + } + + const nextNode = folderMap.get(fullPath); + if (nextNode) { + currentNode = nextNode; + } + currentPath = fullPath; + } + } + + // Second pass: assign pages to their exact folder node (including subfolders) + for (const page of pages) { + if (!page.fmFolder) { + root.pages.push(page); + continue; + } + + const normalizedPath = computeNormalizedFolderPath(page).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + const folderNode = normalizedPath ? folderMap.get(normalizedPath) : folderMap.get(page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')); + if (folderNode) { + folderNode.pages.push(page); + } else { + // If folder not found, add to root as fallback + root.pages.push(page); + } + } + + return root; + }, [pages]); + + const renderFolderNode = (node: FolderNode, depth = 0): React.ReactNode => { + const hasContent = node.pages.length > 0 || node.children.length > 0; + + if (!hasContent) { + return null; + } + + const isRoot = depth === 0; + const paddingLeft = depth * 20; + + if (isRoot) { + // For root node, render children and pages directly + return ( +
+ {/* Root level folders */} + {node.children.map(child => renderFolderNode(child, depth + 1))} + + {/* Root level pages */} + {node.pages.length > 0 && ( +
+

+ Root Files +

+
    + {node.pages.map((page, idx) => ( +
  • + +
  • + ))} +
+
+ )} +
+ ); + } + + return ( +
+ + {({ open }) => ( + <> + + + + + {node.name} + {node.pages.length > 0 && ( + + ({node.pages.length} {node.pages.length === 1 ? 'file' : 'files'}) + + )} + + + + + {/* Child folders */} + {node.children.map(child => renderFolderNode(child, depth + 1))} + + {/* Pages in this folder */} + {node.pages.length > 0 && ( +
    + {node.pages.map((page, idx) => ( +
  • + +
  • + ))} +
+ )} +
+ + )} +
+
+ ); + }; + + return ( +
+ {renderFolderNode(folderTree)} +
+ ); +}; \ No newline at end of file diff --git a/src/dashboardWebView/components/Header/Pagination.tsx b/src/dashboardWebView/components/Header/Pagination.tsx index 5a68be1a..d044f11d 100644 --- a/src/dashboardWebView/components/Header/Pagination.tsx +++ b/src/dashboardWebView/components/Header/Pagination.tsx @@ -2,10 +2,11 @@ import * as React from 'react'; import { useCallback, useEffect, useMemo } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import usePagination from '../../hooks/usePagination'; -import { MediaTotalSelector, PageAtom, SettingsAtom } from '../../state'; +import { MediaTotalSelector, PageAtom, SettingsAtom, ViewSelector } from '../../state'; import { PaginationButton } from './PaginationButton'; import * as l10n from '@vscode/l10n'; import { LocalizationKey } from '../../../localization'; +import { DashboardViewType } from '../../models'; export interface IPaginationProps { totalPages?: number; @@ -17,6 +18,7 @@ export const Pagination: React.FunctionComponent = ({ const [page, setPage] = useRecoilState(PageAtom); const totalMedia = useRecoilValue(MediaTotalSelector); const settings = useRecoilValue(SettingsAtom); + const view = useRecoilValue(ViewSelector); const { pageSetNr, totalPagesNr } = usePagination( settings?.dashboardState.contents.pagination, totalPages, @@ -33,17 +35,17 @@ export const Pagination: React.FunctionComponent = ({ if (i >= 0 && i <= totalPagesNr) { buttons.push( + key={i} + disabled={i === page} + onClick={() => { + setPage(i); + }} + className={`max-h-8 rounded ${page === i + ? `px-2 bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]` + : `text-[var(--vscode-editor-foreground)] hover:text-[var(--vscode-list-activeSelectionForeground)]`}`} + > + {i + 1} + ); } } @@ -58,6 +60,10 @@ export const Pagination: React.FunctionComponent = ({ setPage(0); }, []); + if (view === DashboardViewType.Structure) { + return null; + } + return (
= ( const [view, setView] = useRecoilState(ViewAtom); const settings = useRecoilValue(SettingsSelector); - const toggleView = () => { - const newView = - view === DashboardViewType.Grid ? DashboardViewType.List : DashboardViewType.Grid; + const handleViewChange = (newView: DashboardViewType) => { setView(newView); Messenger.send(DashboardMessage.setPageViewType, newView); }; @@ -36,7 +34,7 @@ export const ViewSwitch: React.FunctionComponent = ( }`} title={l10n.t(LocalizationKey.dashboardHeaderViewSwitchToGrid)} type={`button`} - onClick={toggleView} + onClick={() => handleViewChange(DashboardViewType.Grid)} > @@ -44,17 +42,29 @@ export const ViewSwitch: React.FunctionComponent = ( +
); }; diff --git a/src/dashboardWebView/models/DashboardViewType.ts b/src/dashboardWebView/models/DashboardViewType.ts index 43605dab..521c00d7 100644 --- a/src/dashboardWebView/models/DashboardViewType.ts +++ b/src/dashboardWebView/models/DashboardViewType.ts @@ -1,4 +1,5 @@ export enum DashboardViewType { Grid = 1, - List + List, + Structure } diff --git a/src/dashboardWebView/models/Page.ts b/src/dashboardWebView/models/Page.ts index 034bdb66..29e784c0 100644 --- a/src/dashboardWebView/models/Page.ts +++ b/src/dashboardWebView/models/Page.ts @@ -1,4 +1,4 @@ -import { I18nConfig } from '../../models'; +import { ContentFolder, I18nConfig } from '../../models'; export interface Page { // Properties for caching @@ -20,15 +20,16 @@ export interface Page { fmCategories: string[]; fmContentType: string; fmDateFormat: string | undefined; + fmPageFolder: ContentFolder | undefined; // i18n fields fmDefaultLocale?: boolean; fmLocale?: I18nConfig; - fmTranslations?: { + fmTranslations?: { [locale: string]: { locale: I18nConfig; path: string; - } + }; }; title: string; diff --git a/src/localization/localization.enum.ts b/src/localization/localization.enum.ts index 6f332b9e..312eda56 100644 --- a/src/localization/localization.enum.ts +++ b/src/localization/localization.enum.ts @@ -727,6 +727,10 @@ export enum LocalizationKey { * Change to list */ dashboardHeaderViewSwitchToList = 'dashboard.header.viewSwitch.toList', + /** + * Change to structure + */ + dashboardHeaderViewSwitchToStructure = 'dashboard.header.viewSwitch.toStructure', /** * Support Front Matter */ diff --git a/src/services/PagesParser.ts b/src/services/PagesParser.ts index bd2e339f..8813ecdd 100644 --- a/src/services/PagesParser.ts +++ b/src/services/PagesParser.ts @@ -219,6 +219,7 @@ export class PagesParser { const isDefaultLanguage = await i18n.isDefaultLanguage(filePath); const locale = await i18n.getLocale(filePath); const translations = await i18n.getTranslations(filePath); + const pageFolder = await Folders.getPageFolderByFilePath(filePath); const page: Page = { ...article.data, @@ -241,6 +242,7 @@ export class PagesParser { fmContentType: contentType.name || DEFAULT_CONTENT_TYPE_NAME, fmBody: article?.content || '', fmDateFormat: dateFormat, + fmPageFolder: pageFolder, // i18n properties fmDefaultLocale: isDefaultLanguage, fmLocale: locale,