feat: markdown, github file and UI improvements

This commit is contained in:
Morten Olsen
2023-06-19 16:33:42 +02:00
parent 4ec88717b0
commit 5c23cef034
27 changed files with 767 additions and 26 deletions

View File

@@ -42,11 +42,15 @@
},
"types": "./dist/cjs/types/index.d.ts",
"dependencies": {
"@monaco-editor/react": "^4.5.1",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tabs": "^1.0.4",
"@refocus/sdk": "workspace:^",
"framer-motion": "^10.12.16",
"monaco-editor": "^0.39.0",
"monaco-themes": "^0.4.4",
"react-icons": "^4.9.0",
"react-markdown": "^6.0.3",
"styled-components": "6.0.0-rc.3",

View File

@@ -0,0 +1,161 @@
import styled from 'styled-components';
import { Range, editor as monacoEditor } from 'monaco-editor';
import MonacoEditor from '@monaco-editor/react';
import theme from 'monaco-themes/themes/Dracula.json';
import { useEffect, useMemo, useRef, useState } from 'react';
import { View } from '../view';
import { Typography } from '../../typography';
type CodeEditorProps = {
language?: string;
readOnly?: boolean;
className?: string;
value?: string;
highlight?: string;
setValue: (value: string) => void;
};
const Wrapper = styled.div`
min-height: 300px;
position: relative;
`;
const EditorWrapper = styled.div`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.with-highlight .view-line span span {
opacity: 0.3;
}
.with-highlight .view-line span span.highlight {
opacity: 1;
}
`;
const CodeEditor: React.FC<CodeEditorProps> = ({
language,
value,
setValue,
highlight,
readOnly = false,
className,
}) => {
const ref = useRef<HTMLDivElement>(null);
const [editor, setEditor] = useState<monacoEditor.ICodeEditor | null>(null);
useEffect(() => {
if (!ref.current || !editor) {
return;
}
const observer = new ResizeObserver(() => {
editor.layout();
});
observer.observe(ref.current);
return () => observer.disconnect();
}, [editor]);
const highlightPositions = useMemo(() => {
const items = (highlight || '').split(',').map((item) => {
const [positions, name] = item.split(':');
const [start, end] = positions.split('-');
return {
start: parseInt(start, 10),
end: parseInt(end, 10),
name,
};
});
return items;
}, [highlight]);
useEffect(() => {
if (!editor) {
return;
}
editor.createDecorationsCollection(
highlightPositions?.map(({ start, end }) => ({
range: new Range(start, 1, end, 1),
options: {
isWholeLine: true,
inlineClassName: 'highlight',
},
})) || [],
);
}, [editor, highlightPositions]);
return (
<>
<Wrapper ref={ref} className={className}>
<EditorWrapper>
<MonacoEditor
className={highlight ? 'with-highlight' : 'without-highlight'}
value={value || ''}
onChange={(nextValue) => setValue(nextValue || '')}
beforeMount={(monaco) => {
monaco.editor.defineTheme('theme', theme as any);
monaco.languages.typescript.typescriptDefaults.setCompilerOptions(
{
target: monaco.languages.typescript.ScriptTarget.ESNext,
allowNonTsExtensions: true,
moduleResolution:
monaco.languages.typescript.ModuleResolutionKind.NodeJs,
module: monaco.languages.typescript.ModuleKind.CommonJS,
noEmit: true,
jsx: monaco.languages.typescript.JsxEmit.ReactJSX,
esModuleInterop: true,
typeRoots: ['node_modules/@types'],
},
);
}}
onMount={async (nextEditor) => {
setEditor(nextEditor);
}}
theme="theme"
language={language || 'typescript'}
height="100%"
width="100%"
options={{
readOnly: readOnly,
minimap: {
enabled: false,
},
fontFamily: 'Fira Code',
scrollBeyondLastLine: false,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
},
wordWrap: 'on',
wrappingIndent: 'indent',
tabSize: 2,
}}
/>
</EditorWrapper>
</Wrapper>
{highlightPositions.length > 0 && (
<View $fr $gap="sm" $p="sm">
{highlightPositions?.map(({ start, end, name }) => (
<Typography
variant="overline"
as="button"
onClick={() =>
editor?.revealLinesInCenter(
start,
end,
monacoEditor.ScrollType.Smooth,
)
}
key={`${start}-${end}`}
>
{name ? `${name}: [${start}-${end}]` : `[${start}-${end}]`}
</Typography>
))}
</View>
)}
</>
);
};
export { CodeEditor };

View File

@@ -16,7 +16,10 @@ const Overlay = styled(DialogPrimitives.Overlay)`
const Portal = styled(DialogPrimitives.Portal)``;
const Content = styled(DialogPrimitives.Content)`
const Content = styled(DialogPrimitives.Content)<{
maxWidth?: string;
height?: string;
}>`
z-index: 1000;
overflow-y: auto;
background-color: ${({ theme }) => theme.colors.bg.base100};
@@ -28,9 +31,10 @@ const Content = styled(DialogPrimitives.Content)`
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-width: 450px;
max-width: ${({ maxWidth }) => maxWidth || '600px'};
max-height: 85vh;
padding: 25px;
height: ${({ height }) => height || 'auto'};
box-shadow: 0 0 0 1px ${({ theme }) => theme.colors.bg.highlight};
`;

View File

@@ -8,3 +8,5 @@ export * from './list';
export * from './dropdown';
export * from './button';
export * from './masonry';
export * from './code-editor';
export * from './popover';

View File

@@ -15,6 +15,10 @@ type Props = {
children: React.ReactNode;
};
const Wrapper = styled.div`
position: relative;
`;
const maxColumnWidth = 400;
const gutter = 16;
@@ -104,7 +108,7 @@ const Masonry = ({ children }: Props) => {
const debouncedHeights = useDebounce(heights, 10);
return (
<div ref={ref}>
<Wrapper ref={ref}>
{columnWidth > 0 &&
elements.map((element, index) => (
<ItemWrapper
@@ -122,7 +126,7 @@ const Masonry = ({ children }: Props) => {
{element}
</ItemWrapper>
))}
</div>
</Wrapper>
);
};

View File

@@ -0,0 +1,52 @@
import * as PopoverPrimitives from '@radix-ui/react-popover';
import { motion } from 'framer-motion';
import { styled, css } from 'styled-components';
const content = css`
min-width: 220px;
background: ${({ theme }) => theme.colors.bg.base100};
border-radius: 6px;
padding: 5px;
box-shadow: 0px 10px 38px -10px rgba(22, 23, 24, 0.35),
0px 10px 20px -15px rgba(22, 23, 24, 0.2);
`;
const Root = styled(PopoverPrimitives.Root)``;
const Content = styled(PopoverPrimitives.Content)`
${content}
`;
const Trigger = styled(PopoverPrimitives.Trigger)`
display: flex;
align-items: center;
justify-content: center;
`;
const Portal = styled(PopoverPrimitives.Portal)``;
const OverlayComponent = styled(motion.div)`
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(8px);
`;
const Overlay: React.FC = () => (
<OverlayComponent initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
);
const Arrow = styled(PopoverPrimitives.Arrow)`
fill: ${({ theme }) => theme.colors.bg.base100};
`;
const Popover = Object.assign(Root, {
Root,
Content,
Trigger,
Portal,
Overlay,
Arrow,
});
export { Popover };

View File

@@ -4,15 +4,35 @@ import {
useRemoveBoard,
useSelectBoard,
useSelectedBoard,
useSetBoardName,
} from '@refocus/sdk';
import { IoAddCircleOutline } from 'react-icons/io5';
import styled from 'styled-components';
import { View } from '../../base';
import { Board } from '../board';
import { Tabs } from '../../base/tabs';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
const NotificationBar = styled(View)``;
const Wrapper = styled(View)`
height: 100vh;
`;
const Title: React.FC<{ id: string }> = ({ id }) => {
const boards = useBoards();
const board = useMemo(() => boards[id], [boards, id]);
const setName = useSetBoardName();
return (
<View
$fr
$items="center"
$gap="sm"
$u
as="input"
value={board.name || ''}
onChange={(e) => setName(id, e.target.value)}
/>
);
};
const App: React.FC = () => {
const boards = useBoards();
@@ -30,13 +50,13 @@ const App: React.FC = () => {
}, [addBoardAction]);
return (
<View>
<Wrapper $fc>
<View $f={1}>
<Tabs value={selected} onValueChange={selectBoard}>
<Tabs.List>
{Object.entries(boards).map(([id, board]) => (
<Tabs.Trigger key={id} value={id}>
{board.name}
<Title id={id} />
<Tabs.Close onClick={() => removeBoard(id)} />
</Tabs.Trigger>
))}
@@ -58,8 +78,7 @@ const App: React.FC = () => {
))}
</Tabs>
</View>
<NotificationBar></NotificationBar>
</View>
</Wrapper>
);
};

View File

@@ -9,6 +9,7 @@ import { Masonry, View } from '../../base';
import { Widget } from '../widget';
import { AddWidgetFromUrl } from '../add-from-url';
import { styled } from 'styled-components';
import { CreateWidget } from '../create-widget';
type BoardProps = {
board: Board;
@@ -20,13 +21,17 @@ const ItemWrapper = styled(View)`
border-radius: ${({ theme }) => theme.radii.md}px;
`;
const Wrapper = styled(View)`
height: 100%;
`;
const Board: React.FC<BoardProps> = ({ board, id }) => {
const setWidgetData = useUpdateWidget();
const removeWidget = useRemoveWidget();
const addWidget = useAddWidget();
return (
<View>
<Wrapper>
<View $p="md">
<AddWidgetFromUrl onCreate={(type, data) => addWidget(id, type, data)}>
<AddWidgetFromUrl.Trigger>
@@ -36,6 +41,14 @@ const Board: React.FC<BoardProps> = ({ board, id }) => {
</View>
</AddWidgetFromUrl.Trigger>
</AddWidgetFromUrl>
<CreateWidget onCreate={(type, data) => addWidget(id, type, data)}>
<CreateWidget.Trigger>
<View $fr $items="center" $p="sm" $gap="sm">
<IoAddCircleOutline />
Create
</View>
</CreateWidget.Trigger>
</CreateWidget>
</View>
<View $p="md">
<Masonry>

View File

@@ -1,10 +1,7 @@
import { useState, useCallback } from 'react';
import { Dialog } from '../../base';
import {
WidgetEditor,
WidgetProvider,
useWidgets,
} from '@refocus/sdk';
import { useState, useCallback, useMemo } from 'react';
import { Card, Dialog, View } from '../../base';
import { WidgetEditor, WidgetProvider, useWidgets } from '@refocus/sdk';
import { Typography } from '../../typography';
type CreateWidgetProps = {
onCreate: (name: string, data: any) => void;
@@ -29,7 +26,23 @@ type WidgetSelectorProps = {
};
const WidgetSelector: React.FC<WidgetSelectorProps> = ({ onSelect }) => {
const [search, setSearch] = useState<string>('');
const widgets = useWidgets();
const editableWidgets = useMemo(
() => widgets.filter((widget) => widget.edit),
[widgets],
);
const searchResults = useMemo(
() =>
!search
? editableWidgets
: editableWidgets.filter((widget) =>
widget.name
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase()),
),
[search, editableWidgets],
);
const handleSelect = useCallback(
(id: string) => {
@@ -39,11 +52,33 @@ const WidgetSelector: React.FC<WidgetSelectorProps> = ({ onSelect }) => {
);
return (
<div>
{widgets.map((widget) => (
<button key={widget.id} onClick={() => handleSelect(widget.id)}>
{widget.name}
</button>
<div style={{ height: '50vh' }}>
<Typography
as="input"
placeholder="Search"
value={search}
$u
onChange={(e) => setSearch(e.target.value)}
/>
{searchResults.map((widget) => (
<Card
$fr
$items="center"
$gap="md"
$p="md"
key={widget.id}
onClick={() => handleSelect(widget.id)}
>
<View>
<Typography variant="header">{widget.icon}</Typography>
</View>
<View>
<Typography variant="title">{widget.name}</Typography>
{widget.description && (
<Typography variant="body">{widget.description}</Typography>
)}
</View>
</Card>
))}
</div>
);
@@ -51,16 +86,23 @@ const WidgetSelector: React.FC<WidgetSelectorProps> = ({ onSelect }) => {
const Root: React.FC<CreateWidgetProps> = ({ onCreate, children }) => {
const [id, setId] = useState<string>('');
const [open, setOpen] = useState<boolean>(false);
const handleSave = useCallback(
(data: any) => {
onCreate(id, data);
setOpen(false);
},
[id, onCreate],
);
const handleOpen = useCallback((state: boolean) => {
setOpen(state);
setId('');
}, []);
return (
<Dialog>
<Dialog open={open} onOpenChange={handleOpen}>
{children}
<Dialog.Portal>
<Dialog.Overlay />

View File

@@ -13,10 +13,12 @@ import { MdKeyboardArrowUp } from 'react-icons/md';
import { motion } from 'framer-motion';
import { VscTrash } from 'react-icons/vsc';
import { CgMoreO, CgSync } from 'react-icons/cg';
import { GoScreenFull } from 'react-icons/go';
import { Dialog, View } from '../../base';
import { DropdownMenu } from '../../base';
import { useCallback, useMemo, useState } from 'react';
import { Typography } from '../../typography';
import { NotificationView } from './notification';
type WidgetProps = {
id: string;
@@ -115,7 +117,22 @@ const Widget: React.FC<WidgetProps> = ({
</motion.div>
<Title />
<Spacer />
<NotificationView />
<Update />
<Dialog>
<Dialog.Trigger>
<View $p="sm">
<GoScreenFull />
</View>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content maxWidth="90vw" height="90vh">
<Dialog.CloseButton />
<WidgetView />
</Dialog.Content>
</Dialog.Portal>
</Dialog>
{hasMenu && (
<DropdownMenu>
<DropdownMenu.Trigger>

View File

@@ -0,0 +1,53 @@
import {
useDismissWidgetNotification,
useWidgetNotifications,
} from '@refocus/sdk';
import { IoNotificationsSharp } from 'react-icons/io5';
import { Card, Popover, View } from '../../base';
import { useTheme } from 'styled-components';
const NotificationView: React.FC = () => {
const notifications = useWidgetNotifications();
const dismiss = useDismissWidgetNotification();
const theme = useTheme();
return (
<View>
<Popover>
<Popover.Trigger>
<View $p="sm">
<IoNotificationsSharp
color={
notifications.length > 0
? theme?.colors.bg.highlight
: undefined
}
/>
</View>
</Popover.Trigger>
<Popover.Portal>
<>
<Popover.Overlay />
<Popover.Content>
{notifications.length === 0 && (
<Card $p="sm">No notifications</Card>
)}
{notifications.map((notification) => (
<Card
key={notification.id}
$p="sm"
onClick={() => dismiss(notification.id || '')}
>
{notification.message}
</Card>
))}
<Popover.Arrow />
</Popover.Content>
</>
</Popover.Portal>
</Popover>
</View>
);
};
export { NotificationView };