Dashboard updates to support content creation + search

This commit is contained in:
Elio Struyf
2021-08-25 21:39:26 +02:00
parent 722c0d6888
commit 6cce35de6c
29 changed files with 509 additions and 161 deletions

6
package-lock.json generated
View File

@@ -2359,6 +2359,12 @@
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"dev": true
},
"fuse.js": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.4.6.tgz",
"integrity": "sha512-/gYxR/0VpXmWSfZOIPS3rWwU8SHgsRTwWuXhyb2O6s7aRuVtHtxCkR33bNYu3wyLyNx/Wpv0vU7FZy8Vj53VNw==",
"dev": true
},
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",

View File

@@ -346,10 +346,6 @@
{
"command": "frontMatter.collapseSections",
"when": "false"
},
{
"command": "frontMatter.dashboard",
"when": "frontMatterCanOpenDashboard"
}
],
"view/title": [
@@ -392,6 +388,7 @@
"css-loader": "5.2.7",
"date-fns": "2.23.0",
"downshift": "6.0.6",
"fuse.js": "6.4.6",
"glob": "7.1.6",
"gray-matter": "4.0.2",
"html-loader": "1.3.2",

View File

@@ -1,7 +1,7 @@
import { SETTINGS_CONTENT_STATIC_FOLDERS, SETTING_DATE_FIELD, SETTING_PREVIEW_HOST, SETTING_PREVIEW_PATHNAME, SETTING_SEO_DESCRIPTION_FIELD } from './../constants/settings';
import { ArticleHelper } from './../helpers/ArticleHelper';
import { join } from "path";
import { commands, env, Uri, ViewColumn, Webview, WebviewOptions, WebviewPanel, WebviewPanelOptions, window, workspace } from "vscode";
import { ColorThemeKind, commands, env, ThemeColor, Uri, ViewColumn, Webview, WebviewOptions, WebviewPanel, WebviewPanelOptions, window, workspace } from "vscode";
import { SettingsHelper } from '../helpers';
import { PreviewSettings } from '../models';
import { format } from 'date-fns';
@@ -12,10 +12,13 @@ import { DashboardCommand } from '../pagesView/DashboardCommand';
import { DashboardMessage } from '../pagesView/DashboardMessage';
import { Page } from '../pagesView/models/Page';
import { openFileInEditor } from '../helpers/openFileInEditor';
import { COMMAND_NAME } from '../constants/Extension';
import { Template } from './Template';
export class Dashboard {
private static webview: WebviewPanel | null = null;
private static isDisposed: boolean = true;
/** 
* Init the dashboard
@@ -24,6 +27,16 @@ export class Dashboard {
const folders = Folders.get();
await commands.executeCommand('setContext', CONTEXT.canOpenDashboard, folders && folders.length > 0);
}
public static get isOpen(): boolean {
return !Dashboard.isDisposed;
}
public static reveal() {
if (Dashboard.webview) {
Dashboard.webview.reveal();
}
}
/**
* Open the markdown preview in the editor
@@ -37,9 +50,11 @@ export class Dashboard {
ViewColumn.One,
{
enableScripts: true
}
}
);
Dashboard.isDisposed = false;
Dashboard.webview.iconPath = {
dark: Uri.file(join(extensionPath, 'assets/frontmatter-dark.svg')),
light: Uri.file(join(extensionPath, 'assets/frontmatter.svg'))
@@ -53,18 +68,35 @@ export class Dashboard {
}
});
Dashboard.webview.onDidDispose(() => {
Dashboard.isDisposed = true;
});
Dashboard.webview.webview.onDidReceiveMessage(async (msg) => {
switch(msg.command) {
case DashboardMessage.getData:
Dashboard.getSettings();
Dashboard.getPages();
break;
case DashboardMessage.openFile:
openFileInEditor(msg.data);
break;
case DashboardMessage.createContent:
await commands.executeCommand(COMMAND_NAME.createContent);
break;
}
});
}
private static async getSettings() {
Dashboard.postWebviewMessage({
command: DashboardCommand.settings,
data: {
folders: Folders.get(),
initialized: await Template.isInitialized()
}
});
}
private static async getPages() {
const config = SettingsHelper.getConfig();
@@ -81,36 +113,38 @@ export class Dashboard {
if (folderInfo) {
for (const folder of folderInfo) {
for (const file of folder.lastModified) {
const article = ArticleHelper.getFrontMatterByPath(file.filePath);
if (file.fileName.endsWith(`.md`) || file.fileName.endsWith(`.mdx`)) {
const article = ArticleHelper.getFrontMatterByPath(file.filePath);
if (article?.data.title) {
const page: Page = {
fmGroup: folder.title,
fmModified: file.mtime,
fmFilePath: file.filePath,
fmFileName: file.fileName,
title: article?.data.title,
slug: article?.data.slug,
date: article?.data[dateField] || "",
draft: article?.data.draft,
description: article?.data[descriptionField] || "",
};
if (article?.data.preview && crntWsFolder) {
const previewPath = join(crntWsFolder.uri.fsPath, staticFolder || "", article?.data.preview);
const previewUri = Uri.file(previewPath);
const preview = Dashboard.webview?.webview.asWebviewUri(previewUri);
page.preview = preview?.toString() || "";
if (article?.data.title) {
const page: Page = {
fmGroup: folder.title,
fmModified: file.mtime,
fmFilePath: file.filePath,
fmFileName: file.fileName,
title: article?.data.title,
slug: article?.data.slug,
date: article?.data[dateField] || "",
draft: article?.data.draft,
description: article?.data[descriptionField] || "",
};
if (article?.data.preview && crntWsFolder) {
const previewPath = join(crntWsFolder.uri.fsPath, staticFolder || "", article?.data.preview);
const previewUri = Uri.file(previewPath);
const preview = Dashboard.webview?.webview.asWebviewUri(previewUri);
page.preview = preview?.toString() || "";
}
pages.push(page);
}
pages.push(page);
}
}
}
}
Dashboard.postWebviewMessage({
command: DashboardCommand.data,
command: DashboardCommand.pages,
data: pages
});
}
@@ -136,12 +170,12 @@ export class Dashboard {
<!DOCTYPE html>
<html lang="en" style="width:100%;height:100%;margin:0;padding:0;">
<head>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https://images.unsplash.com/ ${`vscode-file://vscode-app`} ${webView.cspSource} https://api.visitorbadge.io 'self' 'unsafe-inline'; script-src 'nonce-${nonce}'; style-src ${webView.cspSource} 'self' 'unsafe-inline'; font-src ${webView.cspSource}">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${`vscode-file://vscode-app`} ${webView.cspSource} https://api.visitorbadge.io 'self' 'unsafe-inline'; script-src 'nonce-${nonce}'; style-src ${webView.cspSource} 'self' 'unsafe-inline'; font-src ${webView.cspSource}">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Front Matter</title>
<title>Front Matter Dashboard</title>
</head>
<body style="width:100%;height:100%;margin:0;padding:0;background:rgba(250, 250, 250, 1);">
<body style="width:100%;height:100%;margin:0;padding:0;" class="bg-gray-100 text-vulcan-500 dark:bg-vulcan-500 dark:text-whisper-500">
<div id="app" style="width:100%;height:100%;margin:0;padding:0;"></div>
<img style="display:none" src="https://api.visitorbadge.io/api/combined?user=estruyf&repo=frontmatter-usage&countColor=%23263759" alt="Daily usage" />

View File

@@ -108,7 +108,6 @@ export async function activate({ subscriptions, extensionUri, extensionPath }: v
vscode.workspace.onDidChangeConfiguration(() => {
Template.init();
Preview.init();
Dashboard.init();
Folders.updateVsCodeCtx();
const exView = ExplorerView.getInstance();
@@ -147,8 +146,13 @@ export async function activate({ subscriptions, extensionUri, extensionPath }: v
subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.preview, () => Preview.open(extensionPath) ));
// Pages dashboard
Dashboard.init();
subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.dashboard, () => Dashboard.open(extensionPath) ));
subscriptions.push(vscode.commands.registerCommand(COMMAND_NAME.dashboard, () => {
if (Dashboard.isOpen) {
Dashboard.reveal();
} else {
Dashboard.open(extensionPath);
}
}));
// Subscribe all commands
subscriptions.push(

28
src/hooks/useDarkMode.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react';
export default function useDarkMode() {
const setTheme = (elm: HTMLElement) => {
if (elm) {
const darkMode = elm.classList.contains('vscode-dark');
document.documentElement.classList.remove(`${darkMode ? "light" : "dark"}`);
document.documentElement.classList.add(`${darkMode ? "dark" : "light"}`);
}
};
useEffect(() => {
const mutationObserver = new MutationObserver((mutationsList, observer) => {
const last = mutationsList.filter(item => item.type === "attributes" || item.attributeName === 'class').pop();
setTheme(last?.target as HTMLElement);
});
setTheme(document.body);
mutationObserver.observe(document.body, { childList: false, attributes: true })
return () => {
mutationObserver.disconnect();
};
}, ['']);
}

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
export function useDebounce(value: string, delay: number) {
export function useDebounce<T>(value: T, delay: number) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(
() => {

View File

@@ -1,4 +1,5 @@
export enum DashboardCommand {
loading = "loading",
data = "data"
pages = "pages",
settings = "settings"
}

View File

@@ -1,4 +1,6 @@
export enum DashboardMessage {
getData = 'getData',
openFile = 'openFile',
getTheme = 'getTheme',
createContent = 'createContent'
}

View File

@@ -5,50 +5,87 @@ import { Overview } from './Overview';
import { Header } from './Header';
import { Tab } from '../constants/Tab';
import { SortOption } from '../constants/SortOption';
import Fuse from 'fuse.js';
import { Page } from '../models/Page';
import useDarkMode from '../../hooks/useDarkMode';
export interface IDashboardProps {}
export const Dashboard: React.FunctionComponent<IDashboardProps> = (props: React.PropsWithChildren<IDashboardProps>) => {
const { loading, pages } = useMessages();
// TODO: Filter by tag / category
const fuseOptions: Fuse.IFuseOptions<Page> = {
keys: [
"title",
"slug",
"description",
"fmFileName"
]
};
export const Dashboard: React.FunctionComponent<IDashboardProps> = ({}: React.PropsWithChildren<IDashboardProps>) => {
const { loading, pages, settings } = useMessages();
const [ tab, setTab ] = React.useState(Tab.All);
const [ sorting, setSorting ] = React.useState(SortOption.LastModified);
const [ group, setGroup ] = React.useState<string | null>(null);
const [ search, setSearch ] = React.useState<string | null>(null);
const [ pageItems, setPageItems ] = React.useState<Page[]>([]);
useDarkMode();
let pagesToShow = pages;
if (tab === Tab.Published) {
pagesToShow = pages.filter(page => !page.draft);
} else if (tab === Tab.Draft) {
pagesToShow = pages.filter(page => !!page.draft);
} else {
pagesToShow = pages;
}
React.useEffect(() => {
// Check if search needs to be performed
let searchedPages = pages;
if (search) {
const fuse = new Fuse(pages, fuseOptions);
const results = fuse.search(search);
searchedPages = results.map(page => page.item);
}
let pagesSorted = pagesToShow;
if (sorting === SortOption.FileNameAsc) {
pagesSorted = pagesToShow.sort((a, b) => a.fmFileName.toLowerCase().localeCompare(b.fmFileName.toLowerCase()));
} else if (sorting === SortOption.FileNameDesc) {
pagesSorted = pagesToShow.sort((a, b) => b.fmFileName.toLowerCase().localeCompare(a.fmFileName.toLowerCase()));
} else {
pagesSorted = pagesToShow.sort((a, b) => b.fmModified - a.fmModified);
}
// Filter the pages
let pagesToShow = searchedPages;
if (tab === Tab.Published) {
pagesToShow = searchedPages.filter(page => !page.draft);
} else if (tab === Tab.Draft) {
pagesToShow = searchedPages.filter(page => !!page.draft);
} else {
pagesToShow = searchedPages;
}
// Show draft/published
// Filter by draft
// Filter by folder (if multiple)
// TODO: Sort by last modified
// Sort the pages
let pagesSorted = pagesToShow;
if (sorting === SortOption.FileNameAsc) {
pagesSorted = pagesToShow.sort((a, b) => a.fmFileName.toLowerCase().localeCompare(b.fmFileName.toLowerCase()));
} else if (sorting === SortOption.FileNameDesc) {
pagesSorted = pagesToShow.sort((a, b) => b.fmFileName.toLowerCase().localeCompare(a.fmFileName.toLowerCase()));
} else {
pagesSorted = pagesToShow.sort((a, b) => b.fmModified - a.fmModified);
}
if (group) {
pagesSorted = pagesSorted.filter(page => page.fmGroup === group);
}
setPageItems(pagesSorted);
}, [ pages, tab, sorting, group, search ]);
const pageGroups = [...new Set(pages.map(page => page.fmGroup))];
return (
<main className="h-full w-full">
<main className={`h-full w-full`}>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<Header currentTab={tab}
currentSorting={sorting}
groups={pageGroups}
crntGroup={group}
totalPages={pageItems.length}
switchTab={(tabId: Tab) => setTab(tabId)}
switchSorting={(sortId: SortOption) => setSorting(sortId)}
switchGroup={(groupId: string | null) => setGroup(groupId)}
onSearch={(value: string | null) => setSearch(value)}
settings={settings}
/>
<Overview pages={pagesSorted} />
</div>
{ loading ? <Spinner /> : null }
{ loading ? <Spinner /> : <Overview pages={pageItems} settings={settings} /> }
</div>
</main>
);
};

View File

@@ -11,6 +11,6 @@ export const DateField: React.FunctionComponent<IDateFieldProps> = ({value}: Rea
const dateString = format(parsedValue, 'yyyy-MM-dd');
return (
<span className={`text-vulcan-100 text-xs`}>{dateString}</span>
<span className={`text-vulcan-100 dark:text-whisper-900 text-xs`}>{dateString}</span>
);
};

View File

@@ -0,0 +1,65 @@
import { Menu, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/solid';
import * as React from 'react';
import { Fragment } from 'react';
import { MenuItem } from './MenuItem';
export interface IGroupingProps {
groups: string[];
crntGroup: string | null;
switchGroup: (group: string | null) => void;
}
const DEFAULT_TYPE = "All types";
export const Grouping: React.FunctionComponent<IGroupingProps> = ({groups, crntGroup, switchGroup}: React.PropsWithChildren<IGroupingProps>) => {
if (groups.length <= 1) {
return null;
}
return (
<div className="flex items-center ml-6">
<Menu as="div" className="relative z-10 inline-block text-left">
<div>
<span className={`text-gray-500 dark:text-whisper-700 mr-2 font-medium`}>Showing:</span>
<Menu.Button className="group inline-flex justify-center text-sm font-medium text-vulcan-500 hover:text-vulcan-600 dark:text-whisper-500 dark:hover:text-whisper-600">
{crntGroup || DEFAULT_TYPE}
<ChevronDownIcon
className="flex-shrink-0 -mr-1 ml-1 h-5 w-5 text-gray-400 group-hover:text-gray-500 dark:text-whisper-600 dark:group-hover:text-whisper-700"
aria-hidden="true"
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-right absolute right-0 z-10 mt-2 w-40 rounded-md shadow-2xl bg-white dark:bg-vulcan-500 ring-1 ring-vulcan-400 dark:ring-white ring-opacity-5 focus:outline-none text-sm">
<div className="py-1">
<MenuItem
title={DEFAULT_TYPE}
value={null}
isCurrent={!crntGroup}
onClick={switchGroup} />
{groups.map((option) => (
<MenuItem
key={option}
title={option}
value={option}
isCurrent={option === crntGroup}
onClick={switchGroup} />
))}
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
);
};

View File

@@ -1,96 +1,62 @@
import { Menu, Transition } from '@headlessui/react';
import * as React from 'react';
import { Tab } from '../constants/Tab';
import { ChevronDownIcon } from '@heroicons/react/solid';
import { Fragment } from 'react';
import { SortOption } from '../constants/SortOption';
import { Navigation } from './Navigation';
import { Sorting } from './Sorting';
import { Grouping } from './Grouping';
import { MessageHelper } from '../../helpers/MessageHelper';
import { DashboardMessage } from '../DashboardMessage';
import { Searchbox } from './Searchbox';
import { Settings } from '../models/Settings';
export interface IHeaderProps {
settings: Settings;
// Navigation
currentTab: Tab;
currentSorting: SortOption;
totalPages: number;
switchTab: (tabId: Tab) => void;
// Sorting
currentSorting: SortOption;
switchSorting: (sortId: SortOption) => void;
// Grouping
groups: string[];
crntGroup: string | null;
switchGroup: (group: string | null) => void;
// Searching
onSearch: (value: string | null) => void;
}
function classNames(...classes: any[]) {
return classes.filter(Boolean).join(' ')
}
export const Header: React.FunctionComponent<IHeaderProps> = ({currentTab, currentSorting, switchSorting, switchTab, totalPages, crntGroup, groups, switchGroup, onSearch, settings}: React.PropsWithChildren<IHeaderProps>) => {
export const tabs = [
{ name: 'All articles', id: Tab.All},
{ name: 'Published', id: Tab.Published },
{ name: 'In draft', id: Tab.Draft }
];
export const sortOptions = [
{ name: "Last modified", id: SortOption.LastModified },
{ name: "By filename (asc)", id: SortOption.FileNameAsc },
{ name: "By filename (desc)", id: SortOption.FileNameDesc },
];
export const Header: React.FunctionComponent<IHeaderProps> = ({currentTab, currentSorting, switchSorting, switchTab}: React.PropsWithChildren<IHeaderProps>) => {
const createContent = () => {
MessageHelper.sendMessage(DashboardMessage.createContent);
};
return (
<div className="px-4 flex items-center border-b border-gray-200 mb-8 sticky top-0 z-50 bg-gray-50 shadow-sm">
<nav className="flex-1 -mb-px flex space-x-6 xl:space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.name}
className={classNames(
tab.id === currentTab
? 'border-teal-900 text-teal-900'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
'whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm'
)}
aria-current={tab.id === currentTab ? 'page' : undefined}
onClick={() => switchTab(tab.id)}
>
{tab.name}
</button>
))}
</nav>
<div className={`mb-6 sticky top-0 z-40 bg-gray-100 dark:bg-vulcan-500`}>
<div className={`px-4 mb-2 flex items-center justify-between`}>
<Searchbox onSearch={onSearch} />
<button
type="button"
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium text-white dark:text-vulcan-500 bg-teal-600 hover:bg-teal-700 focus:outline-none disabled:bg-gray-500"
onClick={createContent}
disabled={!settings.initialized}
>
Create content
</button>
</div>
<div className="flex items-center ml-6">
<Menu as="div" className="relative z-10 inline-block text-left">
<div>
<Menu.Button className="group inline-flex justify-center text-sm font-medium text-gray-500 hover:text-gray-700">
Sort
<ChevronDownIcon
className="flex-shrink-0 -mr-1 ml-1 h-5 w-5 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
</Menu.Button>
</div>
<div className="px-4 flex items-center border-b border-gray-200 dark:border-whisper-600">
<Navigation currentTab={currentTab} totalPages={totalPages} switchTab={switchTab} />
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-right absolute right-0 z-10 mt-2 w-40 rounded-md shadow-2xl bg-white ring-1 ring-vulcan-400 ring-opacity-5 focus:outline-none text-sm">
<div className="py-1">
{sortOptions.map((option) => (
<Menu.Item key={option.id}>
<button
onClick={() => switchSorting(option.id)}
className={classNames(
option.id === currentSorting ? 'text-vulcan-500' : 'text-gray-500',
'block px-4 py-2 text-sm font-medium w-full text-left hover:text-gray-700'
)}
>
{option.name}
</button>
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
<Grouping crntGroup={crntGroup} groups={groups} switchGroup={switchGroup} />
<Sorting currentSorting={currentSorting} switchSorting={switchSorting} />
</div>
</div>
);

View File

@@ -1,5 +1,6 @@
import * as React from 'react';
import { MessageHelper } from '../../helpers/MessageHelper';
import { MarkdownIcon } from '../../viewpanel/components/Icons/MarkdownIcon';
import { DashboardMessage } from '../DashboardMessage';
import { Page } from '../models/Page';
import { DateField } from './DateField';
@@ -15,19 +16,21 @@ export const Item: React.FunctionComponent<IItemProps> = ({ fmFilePath, date, ti
return (
<li className="relative">
<button className={`group cursor-pointer flex flex-wrap items-start content-start h-full w-full rounded-lg bg-gray-50 text-vulcan-500 text-left overflow-hidden shadow-md hover:shadow-xl`}
<button className={`group cursor-pointer flex flex-wrap items-start content-start h-full w-full bg-gray-50 dark:bg-vulcan-200 text-vulcan-500 dark:text-whisper-500 text-left overflow-hidden shadow-md hover:shadow-xl dark:hover:bg-vulcan-100`}
onClick={openFile}>
<div className="relative h-36 w-full overflow-hidden">
<div className="relative h-36 w-full overflow-hidden border-b border-gray-100 dark:border-vulcan-100 dark:group-hover:border-vulcan-200">
{
preview ? (
<img src={`${preview}`} alt={title} className="absolute inset-0 h-full w-full object-cover" loading="lazy" />
) : (
<img src={`https://images.unsplash.com/photo-1598620617148-c9e8ddee6711?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80`} alt={title} className="absolute inset-0 h-full w-full object-cover group-hover:opacity-95" loading="lazy" />
<div className={`flex items-center justify-center bg-whisper-500 dark:bg-vulcan-200 dark:group-hover:bg-vulcan-100`}>
<MarkdownIcon className={`h-32 text-vulcan-100 dark:text-whisper-100`} />
</div>
)
}
</div>
<div className="p-4">
<div className="p-4 w-full">
<div className={`flex justify-between items-center`}>
<Status draft={!!draft} />
@@ -36,7 +39,7 @@ export const Item: React.FunctionComponent<IItemProps> = ({ fmFilePath, date, ti
<h2 className="mt-2 mb-2 font-bold">{title}</h2>
<p className="text-xs text-vulcan-200">{description}</p>
<p className="text-xs text-vulcan-200 dark:text-whisper-800">{description}</p>
</div>
</button>
</li>

View File

@@ -0,0 +1,22 @@
import { Menu } from '@headlessui/react';
import * as React from 'react';
export interface IMenuItemProps {
title: string;
value: any;
isCurrent: boolean;
onClick: (value: any) => void;
}
export const MenuItem: React.FunctionComponent<IMenuItemProps> = ({title, value, isCurrent, onClick}: React.PropsWithChildren<IMenuItemProps>) => {
return (
<Menu.Item>
<button
onClick={() => onClick(value)}
className={`${!isCurrent ? `text-vulcan-500 dark:text-whisper-500` : `text-gray-500 dark:text-whisper-900`} block px-4 py-2 text-sm font-medium w-full text-left hover:text-gray-700 dark:hover:text-whisper-600`}
>
{title}
</button>
</Menu.Item>
);
};

View File

@@ -0,0 +1,32 @@
import * as React from 'react';
import { Tab } from '../constants/Tab';
export interface INavigationProps {
currentTab: Tab;
totalPages: number;
switchTab: (tabId: Tab) => void;
}
export const tabs = [
{ name: 'All articles', id: Tab.All},
{ name: 'Published', id: Tab.Published },
{ name: 'In draft', id: Tab.Draft }
];
export const Navigation: React.FunctionComponent<INavigationProps> = ({currentTab, totalPages, switchTab}: React.PropsWithChildren<INavigationProps>) => {
return (
<nav className="flex-1 -mb-px flex space-x-6 xl:space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.name}
className={`${tab.id === currentTab ? `border-teal-900 dark:border-teal-300 text-teal-900 dark:text-teal-300` : `border-transparent text-gray-500 dark:text-whisper-600 hover:text-gray-700 dark:hover:text-whisper-700 hover:border-gray-300 dark:hover:border-whisper-500`} whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm`}
aria-current={tab.id === currentTab ? 'page' : undefined}
onClick={() => switchTab(tab.id)}
>
{tab.name}{(tab.id === currentTab && totalPages) ? ` (${totalPages})` : ''}
</button>
))}
</nav>
);
};

View File

@@ -1,13 +1,34 @@
import * as React from 'react';
import { MarkdownIcon } from '../../viewpanel/components/Icons/MarkdownIcon';
import { Page } from '../models/Page';
import { Settings } from '../models/Settings';
import { Item } from './Item';
import { List } from './List';
export interface IOverviewProps {
pages: Page[];
settings: Settings;
}
export const Overview: React.FunctionComponent<IOverviewProps> = ({pages}: React.PropsWithChildren<IOverviewProps>) => {
export const Overview: React.FunctionComponent<IOverviewProps> = ({pages, settings}: React.PropsWithChildren<IOverviewProps>) => {
if (!pages || !pages.length) {
return (
<div className={`flex items-center justify-center mt-16`}>
<div className={`max-w-xl text-center`}>
<MarkdownIcon className={`text-vulcan-300 dark:text-whisper-800 h-32 mx-auto`} />
{
settings?.folders?.length > 0 ? (
<p className={`text-xl font-medium`}>No Markdown to show</p>
) : (
<p className={`text-lg font-medium`}>Make sure you registered a content folder in your project to let Front Matter find the contents.</p>
)
}
</div>
</div>
);
}
return (
<List>

View File

@@ -0,0 +1,42 @@
import { FilterIcon, SearchIcon } from '@heroicons/react/solid';
import * as React from 'react';
import { useDebounce } from '../../hooks/useDebounce';
export interface ISearchboxProps {
onSearch: (searchText: string) => void;
}
export const Searchbox: React.FunctionComponent<ISearchboxProps> = ({onSearch}: React.PropsWithChildren<ISearchboxProps>) => {
const [ value, setValue ] = React.useState('');
const debounceSearch = useDebounce<string>(value, 500);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
};
React.useEffect(() => {
onSearch(debounceSearch);
}, [debounceSearch]);
return (
<div className="flex space-x-4">
<div className="flex-1 min-w-0">
<label htmlFor="search" className="sr-only">Search</label>
<div className="relative flex justify-center">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<SearchIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="search"
name="search"
className={`block w-full py-2 pl-10 pr-3 sm:text-sm bg-white dark:bg-vulcan-300 border border-gray-300 dark:border-vulcan-100 text-vulcan-500 dark:text-whisper-500 placeholder-gray-400 dark:placeholder-whisper-800 focus:outline-none`}
placeholder="Search"
value={value}
autoFocus={true}
onChange={handleChange}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { Menu, Transition } from '@headlessui/react';
import * as React from 'react';
import { SortOption } from '../constants/SortOption';
import { ChevronDownIcon } from '@heroicons/react/solid';
import { Fragment } from 'react';
import { MenuItem } from './MenuItem';
export interface ISortingProps {
currentSorting: SortOption;
switchSorting: (sortId: SortOption) => void;
}
export const sortOptions = [
{ name: "Last modified", id: SortOption.LastModified },
{ name: "By filename (asc)", id: SortOption.FileNameAsc },
{ name: "By filename (desc)", id: SortOption.FileNameDesc },
];
export const Sorting: React.FunctionComponent<ISortingProps> = ({currentSorting, switchSorting}: React.PropsWithChildren<ISortingProps>) => {
const crntSort = sortOptions.find(x => x.id === currentSorting);
return (
<div className="flex items-center ml-6">
<Menu as="div" className="relative z-10 inline-block text-left">
<div>
<span className={`text-gray-500 dark:text-whisper-700 mr-2 font-medium`}>Sort by:</span>
<Menu.Button className="group inline-flex justify-center text-sm font-medium text-vulcan-500 hover:text-vulcan-600 dark:text-whisper-500 dark:hover:text-whisper-600">
{crntSort?.name}
<ChevronDownIcon
className="flex-shrink-0 -mr-1 ml-1 h-5 w-5 text-gray-400 group-hover:text-gray-500 dark:text-whisper-600 dark:group-hover:text-whisper-700"
aria-hidden="true"
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="origin-top-right absolute right-0 z-10 mt-2 w-40 rounded-md shadow-2xl bg-white dark:bg-vulcan-500 ring-1 ring-vulcan-400 dark:ring-white ring-opacity-5 focus:outline-none text-sm">
<div className="py-1">
{sortOptions.map((option) => (
<MenuItem
key={option.id}
title={option.name}
value={option.id}
isCurrent={option.id === currentSorting}
onClick={switchSorting} />
))}
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
);
};

View File

@@ -4,7 +4,7 @@ export interface ISpinnerProps {}
export const Spinner: React.FunctionComponent<ISpinnerProps> = (props: React.PropsWithChildren<ISpinnerProps>) => {
return (
<div className={`fixed top-0 left-0 right-0 bottom-0 w-full h-full flex flex-wrap items-center justify-center bg-white bg-opacity-10 z-40`}>
<div className={`fixed top-0 left-0 right-0 bottom-0 w-full h-full flex flex-wrap items-center justify-center bg-white bg-opacity-10 z-50`}>
<div className="loader ease-linear rounded-full border-8 border-t-8 border-gray-50 h-32 w-32" />
</div>
);

View File

@@ -6,6 +6,6 @@ export interface IStatusProps {
export const Status: React.FunctionComponent<IStatusProps> = ({draft}: React.PropsWithChildren<IStatusProps>) => {
return (
<span className={`inline-block px-2 py-1 leading-none rounded-full font-semibold uppercase tracking-wide text-xs ${draft ? "bg-red-500 text-whisper-200" : "bg-teal-500 text-whisper-500"}`}>{draft ? "Draft" : "Published"}</span>
<span className={`inline-block px-2 py-1 leading-none rounded-full font-semibold uppercase tracking-wide text-xs text-whisper-200 dark:text-vulcan-500 ${draft ? "bg-red-500" : "bg-teal-500"}`}>{draft ? "Draft" : "Published"}</span>
);
};

View File

@@ -1,23 +1,29 @@
import { useState, useEffect } from 'react';
import { MessageHelper } from '../../helpers/MessageHelper';
import { ContentFolder } from '../../models';
import { DashboardCommand } from '../DashboardCommand';
import { DashboardMessage } from '../DashboardMessage';
import { Page } from '../models/Page';
import { Settings } from '../models/Settings';
const vscode = MessageHelper.getVsCodeAPI();
export default function useMessages(options?: any) {
const [loading, setLoading] = useState<boolean>(false);
const [pages, setPages] = useState<Page[]>([]);
const [settings, setSettings] = useState<Settings>({} as any);
window.addEventListener('message', event => {
const message = event.data;
switch (message.command) {
case DashboardCommand.loading:
setLoading(message.data);
break;
case DashboardCommand.data:
case DashboardCommand.settings:
setSettings(message.data);
break;
case DashboardCommand.pages:
setPages(message.data);
setLoading(false);
break;
@@ -26,11 +32,13 @@ export default function useMessages(options?: any) {
useEffect(() => {
setLoading(true);
vscode.postMessage({ command: DashboardMessage.getTheme });
vscode.postMessage({ command: DashboardMessage.getData });
}, ['']);
return {
loading,
pages
pages,
settings
};
}

View File

@@ -0,0 +1,6 @@
import { ContentFolder } from './../../models/ContentFolder';
export interface Settings {
folders: ContentFolder[];
initialized: boolean
}

View File

@@ -4,7 +4,7 @@
.loader {
border-top-color: var(--vscode-activityBar-activeBorder);;
border-top-color: #15c2cb;
animation: spinner 1.5s linear infinite;
}

View File

@@ -24,4 +24,5 @@ export enum CommandToCode {
updatePreviewUrl = "update-preview-url",
openInEditor = "open-in-editor",
updateMetadata = "update-metadata",
openDashboard = "open-dashboard",
}

View File

@@ -16,6 +16,10 @@ export interface IBaseViewProps {
export const BaseView: React.FunctionComponent<IBaseViewProps> = ({settings, folderAndFiles}: React.PropsWithChildren<IBaseViewProps>) => {
const openDashboard = () => {
MessageHelper.sendMessage(CommandToCode.openDashboard);
};
const initProject = () => {
MessageHelper.sendMessage(CommandToCode.initProject);
};
@@ -32,6 +36,7 @@ export const BaseView: React.FunctionComponent<IBaseViewProps> = ({settings, fol
<Collapsible id={`base_actions`} title="Actions">
<div className={`base__actions`}>
<button onClick={openDashboard}>Open dashboard</button>
<button onClick={initProject} disabled={settings?.isInitialized}>Initialize project</button>
<button onClick={createContent} disabled={!settings?.isInitialized}>Create new content</button>
</div>

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import { PanelSettings } from '../../models';
import { CommandToCode } from '../CommandToCode';
import { MessageHelper } from '../../helpers/MessageHelper';
import { useDebounce } from '../hooks/useDebounce';
import { useDebounce } from '../../hooks/useDebounce';
import { Collapsible } from './Collapsible';
import { VsCheckbox, VsLabel } from './VscodeComponents';

View File

@@ -1,9 +1,11 @@
import * as React from 'react';
export interface IMarkdownIconProps {}
export interface IMarkdownIconProps {
className?: string;
}
export const MarkdownIcon: React.FunctionComponent<IMarkdownIconProps> = (props: React.PropsWithChildren<IMarkdownIconProps>) => {
export const MarkdownIcon: React.FunctionComponent<IMarkdownIconProps> = ({className}: React.PropsWithChildren<IMarkdownIconProps>) => {
return (
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M6.345 5h2.1v6.533H6.993l.055-5.31-1.774 5.31H4.072l-1.805-5.31c.04.644.06 5.31.06 5.31H1V5h2.156s1.528 4.493 1.577 4.807L6.345 5zm6.71 3.617v-3.5H11.11v3.5H9.166l2.917 2.916L15 8.617h-1.945z"/></svg>
<svg className={className || ""} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M6.345 5h2.1v6.533H6.993l.055-5.31-1.774 5.31H4.072l-1.805-5.31c.04.644.06 5.31.06 5.31H1V5h2.156s1.528 4.493 1.577 4.807L6.345 5zm6.71 3.617v-3.5H11.11v3.5H9.166l2.917 2.916L15 8.617h-1.945z"/></svg>
);
};

View File

@@ -169,6 +169,9 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
case CommandToCode.openPreview:
await commands.executeCommand(COMMAND_NAME.preview);
break;
case CommandToCode.openDashboard:
await commands.executeCommand(COMMAND_NAME.dashboard);
break;
case CommandToCode.updatePreviewUrl:
this.updatePreviewUrl(msg.data || "");
break;
@@ -516,7 +519,7 @@ export class ExplorerView implements WebviewViewProvider, Disposable {
* @param msg
*/
private postWebviewMessage(msg: { command: Command, data?: any }) {
this.panel!.webview.postMessage(msg);
this.panel?.webview?.postMessage(msg);
}
/**

View File

@@ -3,7 +3,7 @@ const colors = require('tailwindcss/colors');
module.exports = {
mode: 'jit',
purge: ['./src/**/*.{js,jsx,ts,tsx}'],
darkMode: false, // or 'media' or 'class'
darkMode: 'class', // or 'media' or 'class'
theme: {
extend: {
colors: {