mirror of
https://github.com/morten-olsen/refocus.dev.git
synced 2026-02-08 00:46:25 +01:00
feat: markdown, github file and UI improvements
This commit is contained in:
@@ -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",
|
||||
|
||||
161
packages/ui/src/base/code-editor/index.tsx
Normal file
161
packages/ui/src/base/code-editor/index.tsx
Normal 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 };
|
||||
@@ -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};
|
||||
`;
|
||||
|
||||
|
||||
@@ -8,3 +8,5 @@ export * from './list';
|
||||
export * from './dropdown';
|
||||
export * from './button';
|
||||
export * from './masonry';
|
||||
export * from './code-editor';
|
||||
export * from './popover';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
52
packages/ui/src/base/popover/index.tsx
Normal file
52
packages/ui/src/base/popover/index.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
53
packages/ui/src/interface/widget/notification.tsx
Normal file
53
packages/ui/src/interface/widget/notification.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user