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:
@@ -12,6 +12,7 @@ type BoardsContextValue = {
|
||||
selected?: string;
|
||||
boards: Boards;
|
||||
addBoard: (name: string) => void;
|
||||
setName: (id: string, name: string) => void;
|
||||
selectBoard: (id: string) => void;
|
||||
removeBoard: (id: string) => void;
|
||||
addWidget: (boardId: string, type: string, data: string) => void;
|
||||
@@ -46,6 +47,16 @@ const BoardsProvider: React.FC<BoardsProviderProps> = ({
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setName = useCallback((id: string, name: string) => {
|
||||
setBoards((currentBoards) => ({
|
||||
...currentBoards,
|
||||
[id]: {
|
||||
...currentBoards[id],
|
||||
name,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const removeBoard = useCallback((id: string) => {
|
||||
setBoards((currentBoards) => {
|
||||
const copy = { ...currentBoards };
|
||||
@@ -116,6 +127,7 @@ const BoardsProvider: React.FC<BoardsProviderProps> = ({
|
||||
addBoard,
|
||||
removeBoard,
|
||||
addWidget,
|
||||
setName,
|
||||
removeWidget,
|
||||
selectBoard,
|
||||
updateWidget,
|
||||
@@ -126,6 +138,7 @@ const BoardsProvider: React.FC<BoardsProviderProps> = ({
|
||||
addBoard,
|
||||
removeBoard,
|
||||
addWidget,
|
||||
setName,
|
||||
removeWidget,
|
||||
selectBoard,
|
||||
updateWidget,
|
||||
|
||||
@@ -65,6 +65,14 @@ const useUpdateWidget = () => {
|
||||
return context.updateWidget;
|
||||
};
|
||||
|
||||
const useSetBoardName = () => {
|
||||
const context = useContext(BoardsContext);
|
||||
if (!context) {
|
||||
throw new Error('useSetBoardName must be used within a BoardsProvider');
|
||||
}
|
||||
return context.setName;
|
||||
};
|
||||
|
||||
export {
|
||||
useBoards,
|
||||
useSelectedBoard,
|
||||
@@ -74,4 +82,5 @@ export {
|
||||
useRemoveBoard,
|
||||
useSelectBoard,
|
||||
useUpdateWidget,
|
||||
useSetBoardName,
|
||||
};
|
||||
|
||||
@@ -8,5 +8,6 @@ export {
|
||||
useRemoveBoard,
|
||||
useSelectBoard,
|
||||
useUpdateWidget,
|
||||
useSetBoardName,
|
||||
} from './hooks';
|
||||
export * from './types';
|
||||
|
||||
@@ -47,7 +47,7 @@ const WidgetProvider = ({
|
||||
const addGlobalNotification = useNotificationAdd();
|
||||
const dissmissGlobalNotification = useNotificationDismiss();
|
||||
const notifications = useMemo(() => {
|
||||
return globalNotifications.filter((n) => n.view !== ref.current);
|
||||
return globalNotifications.filter((n) => n.view === ref.current);
|
||||
}, [globalNotifications]);
|
||||
|
||||
const addNotification = useCallback(
|
||||
|
||||
@@ -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 };
|
||||
58
packages/widgets/src/github/file/edit.tsx
Normal file
58
packages/widgets/src/github/file/edit.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Props } from './schema';
|
||||
|
||||
type EditorProps = {
|
||||
value?: Props;
|
||||
save: (data: Props) => void;
|
||||
};
|
||||
|
||||
const Edit: React.FC<EditorProps> = ({ value, save }) => {
|
||||
const [owner, setOwner] = useState(value?.owner || '');
|
||||
const [repo, setRepo] = useState(value?.repo || '');
|
||||
const [branch, setBranch] = useState(value?.branch || '');
|
||||
const [path, setPath] = useState(value?.path || '');
|
||||
const [highlight, setHighlight] = useState(value?.highlight || '');
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
save({
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
path,
|
||||
highlight,
|
||||
});
|
||||
}, [owner, repo, branch, path, highlight, save]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
placeholder="Owner"
|
||||
value={owner}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="Repo"
|
||||
value={repo}
|
||||
onChange={(e) => setRepo(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="Branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="Path"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="Highlights"
|
||||
value={highlight}
|
||||
onChange={(e) => setHighlight(e.target.value)}
|
||||
/>
|
||||
<button onClick={handleSave}>Save</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Edit };
|
||||
29
packages/widgets/src/github/file/index.tsx
Normal file
29
packages/widgets/src/github/file/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Widget } from '@refocus/sdk';
|
||||
import { SiGithub } from 'react-icons/si';
|
||||
import { schema } from './schema';
|
||||
import { Edit } from './edit';
|
||||
import { View } from './view';
|
||||
|
||||
const widget: Widget<typeof schema> = {
|
||||
name: 'Github File',
|
||||
description: 'Display a file from a Github repository',
|
||||
icon: <SiGithub />,
|
||||
id: 'github.file',
|
||||
parseUrl: (url) => {
|
||||
if (url.hostname !== 'github.com') {
|
||||
return;
|
||||
}
|
||||
const pathParts = url.pathname.split('/').filter(Boolean);
|
||||
const [owner, repo, type, branch, ...filePathParts] = pathParts.slice(0);
|
||||
const path = filePathParts.join('/');
|
||||
if (type !== 'blob' || !branch || !path) {
|
||||
return;
|
||||
}
|
||||
return { owner, repo, branch, path };
|
||||
},
|
||||
schema,
|
||||
component: View,
|
||||
edit: Edit,
|
||||
};
|
||||
|
||||
export default widget;
|
||||
14
packages/widgets/src/github/file/schema.ts
Normal file
14
packages/widgets/src/github/file/schema.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
|
||||
const schema = Type.Object({
|
||||
owner: Type.String(),
|
||||
repo: Type.String(),
|
||||
path: Type.String(),
|
||||
branch: Type.String(),
|
||||
highlight: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
type Props = Static<typeof schema>;
|
||||
|
||||
export type { Props };
|
||||
export { schema };
|
||||
62
packages/widgets/src/github/file/view.tsx
Normal file
62
packages/widgets/src/github/file/view.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
useAutoUpdate,
|
||||
useGithubQuery,
|
||||
useName,
|
||||
withGithub,
|
||||
} from '@refocus/sdk';
|
||||
import { Props } from './schema';
|
||||
import { CodeEditor, Github, View } from '@refocus/ui';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
const FullHeight = styled(View)`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const StyledCodeEditor = styled(CodeEditor)`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const WidgetView = withGithub<Props>(
|
||||
({ owner, repo, branch, path, highlight }) => {
|
||||
const [, setName] = useName();
|
||||
const { data, fetch } = useGithubQuery(async (client, params: Props) => {
|
||||
const response = await client.rest.repos.getContent({
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
path: params.path,
|
||||
});
|
||||
setName(`${params.owner}/${params.repo}/${params.path}}`);
|
||||
return response.data;
|
||||
});
|
||||
|
||||
useAutoUpdate(
|
||||
{
|
||||
interval: 1000 * 60 * 5,
|
||||
action: async () => fetch({ owner, repo, branch, path }),
|
||||
},
|
||||
[owner, repo, branch, path],
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data || !('type' in data) || data.type !== 'file') {
|
||||
return <div>Not a file</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<FullHeight $fc>
|
||||
<StyledCodeEditor
|
||||
highlight={highlight}
|
||||
value={atob(data.content)}
|
||||
setValue={() => {}}
|
||||
readOnly
|
||||
/>
|
||||
</FullHeight>
|
||||
);
|
||||
},
|
||||
Github.NotLoggedIn,
|
||||
);
|
||||
|
||||
export { WidgetView as View };
|
||||
@@ -4,6 +4,7 @@ import pullRequest from './pull-request/index.widget';
|
||||
import pullRequstComments from './pull-request-comments/index.widget';
|
||||
import workflowRun from './workflow-run/index.widget';
|
||||
import workflowRuns from './workflow-runs/index.widget';
|
||||
import file from './file';
|
||||
|
||||
const github = [
|
||||
githubProfileWidget,
|
||||
@@ -11,6 +12,7 @@ const github = [
|
||||
pullRequstComments,
|
||||
workflowRun,
|
||||
workflowRuns,
|
||||
file,
|
||||
] satisfies Widget<any>[];
|
||||
|
||||
export { github };
|
||||
|
||||
@@ -2,7 +2,13 @@ import { Widget } from '@refocus/sdk';
|
||||
import { github } from './github';
|
||||
import { linear } from './linear';
|
||||
import { slack } from './slack';
|
||||
import markdown from './markdown';
|
||||
|
||||
const widgets = [...linear, ...github, ...slack] satisfies Widget<any>[];
|
||||
const widgets = [
|
||||
...linear,
|
||||
...github,
|
||||
...slack,
|
||||
markdown,
|
||||
] satisfies Widget<any>[];
|
||||
|
||||
export { widgets };
|
||||
|
||||
@@ -10,10 +10,12 @@ const WidgetView = withLinear<LinearIssueProps>(({ id }) => {
|
||||
const issue = await client.issue(id);
|
||||
const assignee = await issue.assignee;
|
||||
const creator = await issue.creator;
|
||||
const state = await issue.state;
|
||||
return {
|
||||
issue,
|
||||
assignee,
|
||||
creator,
|
||||
state,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -34,6 +36,9 @@ const WidgetView = withLinear<LinearIssueProps>(({ id }) => {
|
||||
onClick={() => window.open(data?.issue.url, '_blank')}
|
||||
>
|
||||
<View>
|
||||
<Typography variant="tiny">
|
||||
{data?.state?.name} - {data?.issue.priorityLabel}
|
||||
</Typography>
|
||||
<Typography variant="title">{data?.issue?.title}</Typography>
|
||||
<Typography variant="tiny">
|
||||
{data?.issue.description?.substring(0, 100)}
|
||||
|
||||
36
packages/widgets/src/markdown/edit.tsx
Normal file
36
packages/widgets/src/markdown/edit.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { CodeEditor, Typography } from '@refocus/ui';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Props } from './schema';
|
||||
|
||||
type EditorProps = {
|
||||
value?: Props;
|
||||
save: (data: Props) => void;
|
||||
};
|
||||
|
||||
const Edit: React.FC<EditorProps> = ({ value, save }) => {
|
||||
const [markdown, setMarkdown] = useState(value?.markdown || '');
|
||||
const [name, setName] = useState(value?.name || '');
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
save({
|
||||
name,
|
||||
markdown,
|
||||
});
|
||||
}, [markdown, save, name]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography
|
||||
as="input"
|
||||
$u
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<CodeEditor language="markdown" value={markdown} setValue={setMarkdown} />
|
||||
<button onClick={handleSave}>Save</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { Edit };
|
||||
17
packages/widgets/src/markdown/index.tsx
Normal file
17
packages/widgets/src/markdown/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Widget } from '@refocus/sdk';
|
||||
import { IoLogoMarkdown } from 'react-icons/io';
|
||||
import { schema } from './schema';
|
||||
import { Edit } from './edit';
|
||||
import { View } from './view';
|
||||
|
||||
const widget: Widget<typeof schema> = {
|
||||
name: 'Markdown',
|
||||
description: 'A markdown note',
|
||||
icon: <IoLogoMarkdown />,
|
||||
id: 'text.markdown',
|
||||
schema,
|
||||
component: View,
|
||||
edit: Edit,
|
||||
};
|
||||
|
||||
export default widget;
|
||||
11
packages/widgets/src/markdown/schema.ts
Normal file
11
packages/widgets/src/markdown/schema.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
|
||||
const schema = Type.Object({
|
||||
name: Type.String(),
|
||||
markdown: Type.String(),
|
||||
});
|
||||
|
||||
type Props = Static<typeof schema>;
|
||||
|
||||
export type { Props };
|
||||
export { schema };
|
||||
21
packages/widgets/src/markdown/view.tsx
Normal file
21
packages/widgets/src/markdown/view.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useName } from '@refocus/sdk';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Props } from './schema';
|
||||
import { useEffect } from 'react';
|
||||
import { View } from '@refocus/ui';
|
||||
|
||||
const WidgetView: React.FC<Props> = ({ markdown, name }) => {
|
||||
const [, setName] = useName();
|
||||
|
||||
useEffect(() => {
|
||||
setName(name);
|
||||
}, [name, setName]);
|
||||
|
||||
return (
|
||||
<View $px="md">
|
||||
<ReactMarkdown>{markdown}</ReactMarkdown>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export { WidgetView as View };
|
||||
Reference in New Issue
Block a user