likec4 0.49.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/@likec4/diagrams/diagram/Diagram.js +55 -27
  2. package/dist/@likec4/diagrams/diagram/Edges.js +14 -11
  3. package/dist/@likec4/diagrams/diagram/Nodes.js +212 -23
  4. package/dist/@likec4/diagrams/diagram/icons/ZoomIn.js +2 -1
  5. package/dist/@likec4/diagrams/diagram/shapes/Compound.js +2 -1
  6. package/dist/@likec4/diagrams/diagram/shapes/Edge.js +1 -1
  7. package/dist/@likec4/diagrams/diagram/state/atoms.js +35 -60
  8. package/dist/@likec4/diagrams/diagram/utils.js +14 -0
  9. package/dist/@likec4/diagrams/hooks/useDiagramApi.js +4 -5
  10. package/dist/@likec4/diagrams/hooks/useImageLoader.js +1 -1
  11. package/dist/__app__/likec4.css +8 -5
  12. package/dist/__app__/src/App.jsx +9 -11
  13. package/dist/__app__/src/components/CopyToClipboard.jsx +29 -0
  14. package/dist/__app__/src/components/CopyToClipboard.module.css +16 -0
  15. package/dist/__app__/src/components/DiagramNotFound.jsx +2 -2
  16. package/dist/__app__/src/components/index.js +2 -1
  17. package/dist/__app__/src/components/sidebar/Sidebar.jsx +1 -1
  18. package/dist/__app__/src/components/sidebar/styles.module.css +4 -1
  19. package/dist/__app__/src/components/view-page/DisplayModeSelector.jsx +15 -9
  20. package/dist/__app__/src/components/view-page/Header.jsx +97 -0
  21. package/dist/__app__/src/components/view-page/Header.module.css +24 -0
  22. package/dist/__app__/src/components/view-page/ShareDialog.jsx +23 -15
  23. package/dist/__app__/src/components/view-page/ViewActions.jsx +69 -0
  24. package/dist/__app__/src/data/atoms.js +4 -22
  25. package/dist/__app__/src/data/hooks.js +5 -4
  26. package/dist/__app__/src/data/index-page.js +22 -0
  27. package/dist/__app__/src/likec4-views.js +1 -1
  28. package/dist/__app__/src/pages/index-page/index.jsx +99 -0
  29. package/dist/__app__/src/pages/index-page/index.module.css +20 -0
  30. package/dist/__app__/src/pages/index.js +1 -1
  31. package/dist/__app__/src/pages/useTransparentBackground.js +2 -2
  32. package/dist/__app__/src/pages/view-page/ViewAsReact.jsx +60 -0
  33. package/dist/__app__/src/pages/view-page/index.js +11 -0
  34. package/dist/__app__/src/pages/view-page/other-formats/ViewAsD2.jsx +18 -0
  35. package/dist/__app__/src/pages/view-page/other-formats/ViewAsDot.jsx +33 -0
  36. package/dist/__app__/src/pages/view-page/other-formats/ViewAsMmd.jsx +18 -0
  37. package/dist/__app__/src/pages/view-page/other-formats.jsx +43 -0
  38. package/dist/__app__/src/pages/view-page/view-page.module.css +81 -0
  39. package/dist/__app__/src/pages/view.page.jsx +12 -65
  40. package/dist/__app__/src/router.js +90 -20
  41. package/dist/__app__/src/utils/utils.js +1 -2
  42. package/dist/__app__/tsconfig.json +1 -0
  43. package/dist/cli/index.js +288 -214
  44. package/package.json +15 -16
  45. package/dist/__app__/postcss.config.cjs +0 -11
  46. package/dist/__app__/src/components/view-page/ViewActionsToolbar.jsx +0 -66
  47. package/dist/__app__/src/pages/index.module.css +0 -11
  48. package/dist/__app__/src/pages/index.page.jsx +0 -57
  49. package/dist/__app__/src/pages/view-page.module.css +0 -30
  50. package/dist/__app__/tailwind.config.cjs +0 -17
@@ -1,11 +1,8 @@
1
1
  @import '@radix-ui/themes/styles.css';
2
2
 
3
- @tailwind base;
4
- @tailwind components;
5
- @tailwind utilities;
6
-
7
3
  .radix-themes {
8
4
  --font-weight-bold: 600;
5
+ --color-text-dimmed: var(--gray-a8);
9
6
  }
10
7
 
11
8
  *,
@@ -26,10 +23,16 @@ html, body {
26
23
  }
27
24
 
28
25
  #like4-root {
29
- margin: 0;
26
+ /* margin: 0;
30
27
  padding: 0;
31
28
  width: 100vw;
32
29
  height: 100vh;
30
+ overscroll-behavior: none;
31
+ overflow: hidden; */
32
+ /* & > div {
33
+ position: fixed;
34
+ inset: 0;
35
+ } */
33
36
  }
34
37
 
35
38
  .transparent-bg {
@@ -1,18 +1,18 @@
1
+ import { nonexhaustive } from '@likec4/core';
2
+ import { Theme } from '@radix-ui/themes';
1
3
  import { Provider } from 'jotai';
2
- import { Fragment } from 'react';
4
+ import { Fragment, useDeferredValue } from 'react';
5
+ import { isNil } from 'remeda';
3
6
  import { Sidebar } from './components';
4
- import { ExportPage, IndexPage, EmbedPage, ViewPage } from './pages';
7
+ import { EmbedPage, ExportPage, IndexPage, ViewPage } from './pages';
5
8
  import { useRoute } from './router';
6
- import { Theme } from '@radix-ui/themes';
7
- import { nonexhaustive } from '@likec4/core';
8
- import { isNil } from 'remeda';
9
9
  const Routes = () => {
10
- const r = useRoute();
10
+ const r = useDeferredValue(useRoute());
11
11
  const theme = r.params?.theme;
12
12
  let page = null;
13
13
  switch (r.route) {
14
14
  case 'view': {
15
- page = <ViewPage key='view' viewId={r.params.viewId} showUI={r.showUI}/>;
15
+ page = (<ViewPage key='view' viewId={r.params.viewId} viewMode={r.params.mode} showUI={r.showUI}/>);
16
16
  break;
17
17
  }
18
18
  case 'export': {
@@ -30,11 +30,9 @@ const Routes = () => {
30
30
  default:
31
31
  nonexhaustive(r);
32
32
  }
33
- return (<Theme hasBackground={!!theme} accentColor='indigo' radius='small' appearance={theme}>
33
+ return (<Theme hasBackground={!!theme} accentColor='indigo' radius='small' appearance={theme ?? 'inherit'}>
34
34
  {page}
35
- {r.showUI && (<Fragment key='ui'>
36
- <Sidebar />
37
- </Fragment>)}
35
+ <Fragment key='ui'>{r.showUI && <Sidebar />}</Fragment>
38
36
  </Theme>);
39
37
  };
40
38
  export default function App() {
@@ -0,0 +1,29 @@
1
+ import { CheckCircledIcon, CopyIcon } from '@radix-ui/react-icons';
2
+ import { Box, IconButton, Tooltip } from '@radix-ui/themes';
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import styles from './CopyToClipboard.module.css';
5
+ export function CopyToClipboard({ text }) {
6
+ const [copied, setCopied] = useState(false);
7
+ const copy = useCallback(() => {
8
+ void navigator.clipboard.writeText(text);
9
+ setCopied(true);
10
+ }, [text]);
11
+ useEffect(() => {
12
+ setCopied(false);
13
+ }, [text]);
14
+ useEffect(() => {
15
+ if (copied) {
16
+ const timeout = setTimeout(() => {
17
+ setCopied(false);
18
+ }, 800);
19
+ return () => clearTimeout(timeout);
20
+ }
21
+ }, [copied]);
22
+ return (<Box position='absolute' top={'0'} right={'0'} p={'4'}>
23
+ <Tooltip content={copied ? 'Copied!' : 'Copy to clipboard'} {...(copied ? { open: true } : {})}>
24
+ <IconButton variant='soft' color={copied ? 'green' : undefined} size={'2'} radius='large' onClick={copy} data-copied={copied} className={styles.copyButton}>
25
+ {copied ? (<CheckCircledIcon width={16} height={16}/>) : (<CopyIcon width={16} height={16}/>)}
26
+ </IconButton>
27
+ </Tooltip>
28
+ </Box>);
29
+ }
@@ -0,0 +1,16 @@
1
+ @keyframes copyButton {
2
+ 0% { transform: scale(1); opacity: 1 }
3
+ 40% { transform: scale(1.4); opacity: .7; }
4
+ 60% { transform: scale(0.6); opacity: 1 }
5
+ 80% { transform: scale(0.95) }
6
+ 100% { transform: scale(0.85) }
7
+ }
8
+ .copyButton {
9
+ animation-timing-function: ease-in;
10
+ backdrop-filter: blur(2px);
11
+
12
+ &:where([data-copied='true']) {
13
+ animation-name: copyButton;
14
+ animation-duration: .7s;
15
+ }
16
+ }
@@ -22,8 +22,8 @@ export const DiagramNotFound = ({ viewId }) => {
22
22
  does not exist
23
23
  </Text>
24
24
  <Box pt='2'>
25
- <Button variant='soft' color='amber' className='cursor-pointer' onClick={() => $pages.index.open()}>
26
- Go to overview
25
+ <Button asChild variant='soft' color='amber'>
26
+ <a href={$pages.index.url()}>Go to overview</a>
27
27
  </Button>
28
28
  </Box>
29
29
  </Flex>
@@ -1,4 +1,5 @@
1
1
  export { Sidebar } from './sidebar/Sidebar';
2
- export { ViewActionsToolbar } from './view-page/ViewActionsToolbar';
2
+ export { ViewActions } from './view-page/ViewActions';
3
3
  export { ThemePanelToggle } from './ThemePanelToggle';
4
4
  export { DiagramNotFound } from './DiagramNotFound';
5
+ export { CopyToClipboard } from './CopyToClipboard';
@@ -11,7 +11,7 @@ export const Sidebar = () => {
11
11
  const [isOpened, toggle] = useToggle(false, true);
12
12
  useClickOutside(ref, () => isOpened && toggle());
13
13
  return (<>
14
- <Flex position='fixed' left='0' p={'2'} className={cn(styles.trigger, 'inset-y-0 cursor-pointer items-start', isOpened && 'display-none')} onClick={toggle}>
14
+ <Flex position='fixed' left='0' top={'0'} bottom={'0'} p={'2'} justify={'start'} data-opened={isOpened} className={cn(styles.trigger)} onClick={toggle}>
15
15
  <IconButton size='2' color='gray' variant='soft'>
16
16
  <HamburgerMenuIcon width={22} height={22}/>
17
17
  </IconButton>
@@ -1,7 +1,10 @@
1
1
  .trigger {
2
-
3
2
  cursor: pointer;
4
3
 
4
+ &[data-opened='true'] {
5
+ visibility: hidden;
6
+ }
7
+
5
8
  &::before {
6
9
  transition-property: all;
7
10
  transition-timing-function: cubic-bezier(0, 0.31, 0, 1.03);
@@ -1,7 +1,8 @@
1
1
  import { CaretDownIcon } from '@radix-ui/react-icons';
2
2
  import { Button, DropdownMenu, Flex, IconButton, Separator } from '@radix-ui/themes';
3
- import { useState } from 'react';
3
+ import { useEffect, useState } from 'react';
4
4
  import { keys } from 'remeda';
5
+ import { updateSearchParams, useSearchParams } from '../../router';
5
6
  const Mode = {
6
7
  react: 'React',
7
8
  dot: 'Graphviz',
@@ -10,19 +11,24 @@ const Mode = {
10
11
  };
11
12
  const mode_keys = keys.strict(Mode);
12
13
  export const DisplayModeSelector = () => {
13
- const [current, setCurrent] = useState('react');
14
+ const current = useSearchParams().mode ?? 'react';
14
15
  const [[first, second, ...rest], setModes] = useState(mode_keys);
15
16
  const changeMode = (mode) => () => {
16
17
  if (mode === current) {
17
18
  return;
18
19
  }
19
- setCurrent(mode);
20
- if (mode === first || mode === second) {
21
- return;
22
- }
23
- // change only second
24
- setModes(([first, ...rest]) => [first, mode, ...rest.filter(m => m !== mode)]);
20
+ updateSearchParams({ mode });
25
21
  };
22
+ useEffect(() => {
23
+ // change only second
24
+ setModes(modes => {
25
+ if (modes[0] === current || modes[1] === current) {
26
+ return modes;
27
+ }
28
+ const [first, ...rest] = modes;
29
+ return [first, current, ...rest.filter(m => m !== current)];
30
+ });
31
+ }, [current]);
26
32
  return (<Flex display={{
27
33
  initial: 'none',
28
34
  sm: 'flex'
@@ -39,7 +45,7 @@ export const DisplayModeSelector = () => {
39
45
  <CaretDownIcon />
40
46
  </IconButton>
41
47
  </DropdownMenu.Trigger>
42
- <DropdownMenu.Content>
48
+ <DropdownMenu.Content size={'1'} side='bottom' align='end'>
43
49
  {rest.map(mode => (<DropdownMenu.Item key={mode} onClick={changeMode(mode)}>
44
50
  {Mode[mode]}
45
51
  </DropdownMenu.Item>))}
@@ -0,0 +1,97 @@
1
+ import { ExternalLinkIcon, Link2Icon } from '@radix-ui/react-icons';
2
+ import { Box, Code, Flex, Heading, HoverCard, IconButton, Link, Text, Tooltip } from '@radix-ui/themes';
3
+ import { isEmpty } from 'remeda';
4
+ import { ViewActions } from './ViewActions';
5
+ import styles from './Header.module.css';
6
+ export function Header({ diagram }) {
7
+ return (<Flex position={'fixed'} top='0' left='0' width={'100%'} className={styles.header} justify='between' align={'stretch'} gap={'4'} p={'2'}>
8
+ <Flex pl='7' grow='1' gap={'2'} shrink='1' align={'stretch'} wrap={'nowrap'}>
9
+ <DiagramTitle diagram={diagram}/>
10
+ <DiagramLinks diagram={diagram}/>
11
+ </Flex>
12
+ <ViewActions diagram={diagram}/>
13
+ </Flex>);
14
+ }
15
+ function DiagramTitle({ diagram }) {
16
+ const hasDescription = !isEmpty(diagram.description?.trim());
17
+ return (<HoverCard.Root closeDelay={500}>
18
+ <HoverCard.Trigger>
19
+ <Flex px={'3'} className={styles.title} align={'center'}>
20
+ <Heading size={{
21
+ initial: '2',
22
+ sm: '3',
23
+ md: '4'
24
+ }} trim={'both'} weight={'medium'}>
25
+ {diagram.title || 'Untitled'}
26
+ </Heading>
27
+ </Flex>
28
+ </HoverCard.Trigger>
29
+ <HoverCard.Content size={'2'} className={styles.titleHoverCardContent}>
30
+ <Flex direction='column' gap='3'>
31
+ <HoverCardItem title='view id'>
32
+ <Code color='gray' size='2'>
33
+ {diagram.id}
34
+ </Code>
35
+ </HoverCardItem>
36
+ {diagram.viewOf && (<HoverCardItem title='view of'>
37
+ <Code size='2'>{diagram.viewOf}</Code>
38
+ </HoverCardItem>)}
39
+ <HoverCardItem title='description'>
40
+ {hasDescription ? (<Text as='p' size='2' style={{ whiteSpace: 'pre-line' }}>
41
+ {diagram.description?.trim()}
42
+ </Text>) : (<Text as='p' size='2' className={styles.dimmed}>
43
+ no description
44
+ </Text>)}
45
+ </HoverCardItem>
46
+ </Flex>
47
+ </HoverCard.Content>
48
+ </HoverCard.Root>);
49
+ }
50
+ function HoverCardItem({ title, children }) {
51
+ return (<Box>
52
+ <Text as='p' size='1' color='gray'>
53
+ {title}
54
+ </Text>
55
+ {children}
56
+ </Box>);
57
+ }
58
+ function DiagramLinks({ diagram: { links } }) {
59
+ if (!links) {
60
+ return null;
61
+ }
62
+ if (links.length > 1) {
63
+ return (<Flex align={'center'}>
64
+ <Box grow={'0'} height={'4'}>
65
+ <HoverCard.Root closeDelay={500}>
66
+ <HoverCard.Trigger>
67
+ <IconButton color='gray' variant='ghost' size={'2'}>
68
+ <Link2Icon width={16} height={16}/>
69
+ </IconButton>
70
+ </HoverCard.Trigger>
71
+ <HoverCard.Content size={'2'} align='center'>
72
+ <Flex direction='column' gap='2'>
73
+ {links.map(link => (<Flex asChild align={'center'} gap={'2'} key={link}>
74
+ <Link href={link} target='_blank'>
75
+ <ExternalLinkIcon width={13} height={13}/>
76
+ <Text size='2'>{link}</Text>
77
+ </Link>
78
+ </Flex>))}
79
+ </Flex>
80
+ </HoverCard.Content>
81
+ </HoverCard.Root>
82
+ </Box>
83
+ </Flex>);
84
+ }
85
+ const link = links[0];
86
+ return (<Flex align={'center'}>
87
+ <Tooltip content={link}>
88
+ <Box grow={'0'}>
89
+ <IconButton asChild color='gray' variant='ghost' size={'2'}>
90
+ <Link href={link} target='_blank'>
91
+ <Link2Icon width={16} height={16}/>
92
+ </Link>
93
+ </IconButton>
94
+ </Box>
95
+ </Tooltip>
96
+ </Flex>);
97
+ }
@@ -0,0 +1,24 @@
1
+ .header {
2
+ background: var(--color-panel-translucent);
3
+ border-bottom: 1px solid var(--gray-a2);
4
+ }
5
+
6
+ .dimmed {
7
+ color: var(--color-text-dimmed);
8
+ }
9
+
10
+ /* diagram title */
11
+ .title {
12
+ border-radius: var(--radius-2);
13
+ cursor: var(--cursor-link);
14
+
15
+ &:hover {
16
+ background-color: var(--gray-a3);
17
+ }
18
+ }
19
+
20
+ .titleHoverCardContent {
21
+ width: var(--radix-hover-card-trigger-width);
22
+ min-width: 200px;
23
+ max-width: 500px;
24
+ }
@@ -1,24 +1,25 @@
1
1
  import { ExclamationTriangleIcon, InfoCircledIcon, OpenInNewWindowIcon } from '@radix-ui/react-icons';
2
- import { Box, Button, Callout, Code, Dialog, Flex, Link, Select, Tabs, Text } from '@radix-ui/themes';
2
+ import { Box, Button, Callout, Code, Dialog, Flex, Link, ScrollArea, Select, Tabs, Text } from '@radix-ui/themes';
3
3
  import { useState } from 'react';
4
4
  import { $pages } from '../../router';
5
+ import { CopyToClipboard } from '../CopyToClipboard';
5
6
  const embedCode = (diagram, theme) => {
7
+ const url = new URL($pages.embed.path(diagram.id), window.location.href);
6
8
  const padding = 20;
7
- const params = new URLSearchParams();
8
- params.set('padding', `${padding}`);
9
+ url.searchParams.set('padding', `${padding}`);
9
10
  if (theme !== 'system') {
10
- params.set('theme', theme);
11
+ url.searchParams.set('theme', theme);
12
+ }
13
+ else {
14
+ url.searchParams.delete('theme');
11
15
  }
12
16
  const width = diagram.width + padding * 2;
13
17
  const height = diagram.height + padding * 2;
14
- const url = new URL(window.location.href);
15
- url.pathname = $pages.embed.path(diagram.id);
16
- url.search = params.toString();
17
- const iframe = `<iframe src="${url.href}" width="100%" height="100%" style="border:0;background:transparent;"></iframe>`;
18
18
  const code = `
19
- <div style="aspect-ratio:${width}/${height};max-width:${width}px;width:100%;height:auto;padding:0;margin-left:auto;margin-right:auto">
20
- ${iframe}
21
- </div>`.trim();
19
+ <div style="aspect-ratio:${width}/${height};width:100%;height:auto;max-width:${width}px;margin:0 auto">
20
+ <iframe src="${url.href}" width="100%" height="100%" style="border:0;background:transparent;"></iframe>
21
+ </div>
22
+ `.trim();
22
23
  return {
23
24
  code,
24
25
  href: url.href
@@ -64,10 +65,17 @@ export const ShareDialog = ({ diagram }) => {
64
65
  </Link>
65
66
  </Flex>
66
67
  </Flex>
67
- <Box asChild display={'block'} my='2' p='2' className='whitespace-pre-wrap overflow-scroll select-all'>
68
- <Code variant='soft' autoFocus>
69
- {code}
70
- </Code>
68
+ <Box position={'relative'} mt={'1'}>
69
+ <ScrollArea scrollbars='both' style={{ maxHeight: 200 }}>
70
+ <Box asChild display={'block'} px='2' py='3' style={{
71
+ whiteSpace: 'pre'
72
+ }}>
73
+ <Code variant='soft' autoFocus>
74
+ {code}
75
+ </Code>
76
+ </Box>
77
+ </ScrollArea>
78
+ <CopyToClipboard text={code}/>
71
79
  </Box>
72
80
  </label>
73
81
  <Text as='div' size='2' color='gray' trim={'start'}>
@@ -0,0 +1,69 @@
1
+ import { CaretDownIcon, Share1Icon as ShareIcon } from '@radix-ui/react-icons';
2
+ import { Button, Dialog, DropdownMenu, Flex, Text } from '@radix-ui/themes';
3
+ import React, { useState } from 'react';
4
+ // import { ThemePanelToggle } from '../ThemePanelToggle'
5
+ import { DisplayModeSelector } from './DisplayModeSelector';
6
+ import ExportDiagram from './ExportDiagram';
7
+ import { ShareDialog } from './ShareDialog';
8
+ import { updateSearchParams } from '../../router';
9
+ const ExportMenu = ({ onExport, children }) => {
10
+ const changeMode = (mode) => (_) => {
11
+ updateSearchParams({ mode });
12
+ };
13
+ return (<DropdownMenu.Root>
14
+ <DropdownMenu.Trigger>{children}</DropdownMenu.Trigger>
15
+ <DropdownMenu.Content variant='soft'>
16
+ <DropdownMenu.Label>
17
+ <Text weight='medium'>Current view</Text>
18
+ </DropdownMenu.Label>
19
+ <DropdownMenu.Group>
20
+ <DropdownMenu.Item onClick={_ => {
21
+ onExport('png');
22
+ }}>
23
+ Export as .png
24
+ </DropdownMenu.Item>
25
+ <DropdownMenu.Item onClick={changeMode('dot')}>Export as .dot</DropdownMenu.Item>
26
+ <DropdownMenu.Item onClick={changeMode('mmd')}>Export as .mmd</DropdownMenu.Item>
27
+ <DropdownMenu.Item onClick={changeMode('d2')}>Export as .d2</DropdownMenu.Item>
28
+ <DropdownMenu.Item disabled>Export to Draw.io</DropdownMenu.Item>
29
+ <DropdownMenu.Item disabled>Export to Miro</DropdownMenu.Item>
30
+ </DropdownMenu.Group>
31
+ <DropdownMenu.Separator />
32
+ <DropdownMenu.Label>
33
+ <Text weight='medium'>All views</Text>
34
+ </DropdownMenu.Label>
35
+ <DropdownMenu.Group>
36
+ <DropdownMenu.Item disabled>Download as ZIP</DropdownMenu.Item>
37
+ </DropdownMenu.Group>
38
+ </DropdownMenu.Content>
39
+ </DropdownMenu.Root>);
40
+ };
41
+ export const ViewActions = ({ diagram }) => {
42
+ const [exportTo, setExportTo] = useState(null);
43
+ return (<Flex shrink={'0'} grow={'0'} gap={'3'} align='center' wrap={'nowrap'}>
44
+ <DisplayModeSelector />
45
+ <Dialog.Root>
46
+ <Dialog.Trigger>
47
+ <Button variant='solid' size={{
48
+ initial: '1',
49
+ md: '2'
50
+ }}>
51
+ <ShareIcon />
52
+ <Text>Share</Text>
53
+ </Button>
54
+ </Dialog.Trigger>
55
+ <ShareDialog diagram={diagram}/>
56
+ </Dialog.Root>
57
+ <ExportMenu onExport={setExportTo}>
58
+ <Button variant='soft' color='gray' size={{
59
+ initial: '1',
60
+ md: '2'
61
+ }}>
62
+ <Text>Export</Text>
63
+ <CaretDownIcon />
64
+ </Button>
65
+ </ExportMenu>
66
+ {exportTo === 'png' && (<ExportDiagram key={'export-diagram-png'} diagram={diagram} onCompleted={() => setExportTo(null)}/>)}
67
+ {/* <ThemePanelToggle /> */}
68
+ </Flex>);
69
+ };
@@ -1,7 +1,7 @@
1
1
  import { atom } from 'jotai';
2
- import { atomFamily, atomWithReducer, splitAtom } from 'jotai/utils';
3
- import { equals, groupBy, mapObject, values } from 'rambdax';
4
- import { LikeC4Views } from '~likec4';
2
+ import { atomFamily, atomWithReducer } from 'jotai/utils';
3
+ import { equals, mapObject, values } from 'rambdax';
4
+ import { LikeC4Views } from 'virtual:likec4/views';
5
5
  import { buildDiagramTreeAtom } from './sidebar-diagram-tree';
6
6
  function atomWithCompare(initialValue) {
7
7
  return atomWithReducer(initialValue, (prev, next) => {
@@ -27,24 +27,6 @@ export const viewsAtom = atom(get => get(_viewsAtom), (_, set, update) => {
27
27
  }, update));
28
28
  });
29
29
  viewsAtom.debugLabel = 'views';
30
- const indexPageTilesAtom = atom(get => {
31
- const views = values(get(viewsAtom));
32
- const byPath = groupBy(v => get(v).relativePath ?? '', views);
33
- return Object.entries(byPath)
34
- .map(([path, views]) => ({
35
- path,
36
- isRoot: path === '',
37
- views
38
- }))
39
- .sort((a, b) => {
40
- return a.path.localeCompare(b.path);
41
- });
42
- });
43
- const byPath = (tile) => tile.path;
44
- export const indexPageTilesAtomsAtom = splitAtom(indexPageTilesAtom, byPath);
45
- // export type DashboardTile = ExtractAtomValue<typeof dashboardTilesAtom>[number]
46
- // const key = (tile: DashboardTile) => tile.path
47
- // export const diagramsTreeAtom = atom(buildDiagramTreeAtom(LikeC4Views))
48
30
  export const diagramsTreeAtom = atom(get => {
49
31
  const views = values(get(viewsAtom));
50
32
  return buildDiagramTreeAtom(views.map(v => get(v)));
@@ -63,7 +45,7 @@ if (import.meta.hot) {
63
45
  $updateViews = undefined;
64
46
  };
65
47
  };
66
- import.meta.hot.accept('/@vite-plugin-likec4/likec4-generated', md => {
48
+ import.meta.hot.accept('/@vite-plugin-likec4/likec4-views', md => {
67
49
  const update = md?.LikeC4Views;
68
50
  if (update) {
69
51
  $updateViews?.(update);
@@ -1,11 +1,12 @@
1
1
  import { useAtomValue } from 'jotai';
2
2
  import { useMemo } from 'react';
3
- import { diagramsTreeAtom, indexPageTilesAtomsAtom, selectLikeC4ViewAtom } from './atoms';
3
+ import { diagramsTreeAtom, selectLikeC4ViewAtom } from './atoms';
4
+ import { viewsGroupAtomsAtom } from './index-page';
4
5
  export const useLikeC4View = (viewId) => {
5
6
  const anAtom = useMemo(() => selectLikeC4ViewAtom(viewId), [viewId]);
6
7
  return useAtomValue(anAtom);
7
8
  };
8
- export const useIndexPageTileAtoms = () => {
9
- return useAtomValue(indexPageTilesAtomsAtom);
10
- };
11
9
  export const useDiagramsTree = () => useAtomValue(diagramsTreeAtom);
10
+ export const useViewGroupsAtoms = () => {
11
+ return useAtomValue(viewsGroupAtomsAtom);
12
+ };
@@ -0,0 +1,22 @@
1
+ import { atom } from 'jotai';
2
+ import { viewsAtom } from './atoms';
3
+ import { groupBy, values } from 'remeda';
4
+ import { splitAtom } from 'jotai/utils';
5
+ /**
6
+ * Views grouped by folder
7
+ */
8
+ const viewGroupsAtom = atom(get => {
9
+ const views = values(get(viewsAtom));
10
+ const byPath = groupBy(views, v => get(v).relativePath ?? '');
11
+ return Object.entries(byPath)
12
+ .map(([path, views]) => ({
13
+ path,
14
+ isRoot: path === '',
15
+ views
16
+ }))
17
+ .sort((a, b) => {
18
+ return a.path.localeCompare(b.path);
19
+ });
20
+ });
21
+ const byPath = (tile) => tile.path;
22
+ export const viewsGroupAtomsAtom = splitAtom(viewGroupsAtom, byPath);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Script for embedding LikeC4Views in a web page.
3
3
  */
4
- import { LikeC4Views } from '~likec4-dimensions';
4
+ import { LikeC4Views } from 'virtual:likec4/dimensions';
5
5
  let BASE = import.meta.env.BASE_URL;
6
6
  if (!BASE.endsWith('/')) {
7
7
  BASE = BASE + '/';
@@ -0,0 +1,99 @@
1
+ import { Diagram } from '@likec4/diagrams';
2
+ import { Box, Card, Container, Flex, Heading, IconButton, Inset, Section, Separator, Text } from '@radix-ui/themes';
3
+ import { useDebouncedEffect } from '@react-hookz/web/esm';
4
+ import { useAtomValue } from 'jotai';
5
+ import { memo, useState } from 'react';
6
+ import { isEmpty } from 'remeda';
7
+ import { useViewGroupsAtoms } from '../../data';
8
+ import { $pages } from '../../router';
9
+ import { cn } from '../../utils';
10
+ import styles from './index.module.css';
11
+ import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
12
+ const DiagramPreview = memo((props) => {
13
+ const [diagram, setDiagram] = useState(null);
14
+ // defer rendering to update to avoid flickering
15
+ useDebouncedEffect(() => {
16
+ setDiagram(props.diagram);
17
+ }, [props.diagram], 50);
18
+ return (<Box className={styles.previewBg} style={{ width: 350, height: 175 }}>
19
+ {diagram && (<Diagram animate={false} pannable={false} zoomable={false} minZoom={0.1} maxZoom={1} diagram={diagram} padding={[4, 6, 4, 6]} width={350} height={175}/>)}
20
+ </Box>);
21
+ });
22
+ const ViewCard = ({ atom }) => {
23
+ // const diagram =
24
+ const diagram = useAtomValue(atom);
25
+ const { id, title, description } = diagram;
26
+ return (<Box asChild shrink='0' grow='1'>
27
+ <Card asChild style={{ width: 350, maxWidth: 350 }} variant='surface' size='1'>
28
+ <a href={$pages.view.url(id)}>
29
+ <Inset clip='padding-box' side='top' pb='current'>
30
+ <DiagramPreview diagram={diagram}/>
31
+ </Inset>
32
+ <Text as='div' size='2' weight='bold' trim='start'>
33
+ {title || id}
34
+ </Text>
35
+ <Text as='div' color='gray' size='2' my='1' className={cn(isEmpty(description?.trim()) && styles.dimmed)} style={{
36
+ whiteSpace: 'pre-line'
37
+ }}>
38
+ {description?.trim() || 'no description'}
39
+ </Text>
40
+ </a>
41
+ </Card>
42
+ </Box>);
43
+ };
44
+ function ViewsGroup({ atom }) {
45
+ const { path, views, isRoot } = useAtomValue(atom);
46
+ return (<Flex asChild gap={'4'} direction={'column'}>
47
+ <Section size='2'>
48
+ <Flex gap='2'>
49
+ <Heading color={isRoot ? undefined : 'gray'} className={cn(isRoot || styles.dimmed)} trim='end'>
50
+ views
51
+ </Heading>
52
+ {!isRoot && (<>
53
+ <Heading color='gray' className={styles.dimmed} trim={'end'}>
54
+ /
55
+ </Heading>
56
+ <Heading trim={'end'}>{path}</Heading>
57
+ </>)}
58
+ </Flex>
59
+ <Separator orientation='horizontal' my='2' size={'4'}/>
60
+ <Flex gap={{
61
+ initial: '4',
62
+ md: '6'
63
+ }} wrap={{
64
+ initial: 'nowrap',
65
+ md: 'wrap'
66
+ }} direction={{
67
+ initial: 'column',
68
+ md: 'row'
69
+ }} align='stretch'>
70
+ {views.map(v => (<ViewCard key={v.toString()} atom={v}/>))}
71
+ </Flex>
72
+ </Section>
73
+ </Flex>);
74
+ }
75
+ export function IndexPage() {
76
+ const viewGroupsAtoms = useViewGroupsAtoms();
77
+ return (<Container size={'4'} px={{
78
+ initial: '3',
79
+ lg: '1'
80
+ }}>
81
+ {viewGroupsAtoms.map(g => (<ViewsGroup key={g.toString()} atom={g}/>))}
82
+ {viewGroupsAtoms.length === 0 && (<Flex position='fixed' inset='0' align='center' justify='center'>
83
+ <Card color='red' size='4'>
84
+ <Flex gap='4' direction='row' align='center'>
85
+ <Box grow='0' shrink='0' pt='1'>
86
+ <IconButton variant='ghost' color='red'>
87
+ <ExclamationTriangleIcon width={20} height={20}/>
88
+ </IconButton>
89
+ </Box>
90
+ <Flex gap='3' direction='column'>
91
+ <Heading trim='both' color='red' size='4'>
92
+ No diagrams found
93
+ </Heading>
94
+ </Flex>
95
+ </Flex>
96
+ </Card>
97
+ </Flex>)}
98
+ </Container>);
99
+ }