├── .eslintrc.json
├── .github
└── ISSUE_TEMPLATE
│ ├── bug-report.yml
│ └── feature-request.yml
├── .gitignore
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── app
├── analytics-wrapper.tsx
├── favicon.ico
├── index.css
├── layout.tsx
├── page.tsx
└── providers.tsx
├── next.config.js
├── package.json
├── postcss.config.js
├── prettier.config.js
├── public
├── json-sea-logo-title.png
├── map-marker.png
├── oceania.png
├── og-image.png
├── screenshot-dark.png
├── screenshot-light.png
├── sea-audio.mp3
├── sea-image.jpeg
└── sea-video.mp4
├── src
├── api
│ ├── json-link-api
│ │ ├── json-link.types.ts
│ │ └── useJsonLinkApi.ts
│ └── media-head-api
│ │ ├── media-head.types.ts
│ │ └── useMediaHeadApi.ts
├── environment.ts
├── foundation
│ └── components
│ │ ├── Copyright.tsx
│ │ ├── GithubButton.tsx
│ │ ├── GlobalNav.tsx
│ │ ├── JsonEditorToggle.tsx
│ │ ├── JsonSeaLogoTitle.tsx
│ │ ├── LandingLoader.tsx
│ │ ├── LocalhostChip.tsx
│ │ ├── Main.tsx
│ │ ├── SettingsButton.tsx
│ │ └── ThemeToggle.tsx
├── json-diagram
│ ├── components
│ │ ├── ArrayNode.tsx
│ │ ├── ChainEdge.tsx
│ │ ├── ChainHandle.tsx
│ │ ├── CustomMiniMap.tsx
│ │ ├── DefaultEdge.tsx
│ │ ├── DefaultHandle.tsx
│ │ ├── DownloadImageButton.tsx
│ │ ├── FitViewInvoker.tsx
│ │ ├── HoveringBlueDot.tsx
│ │ ├── JsonDiagram.tsx
│ │ ├── NodeShell.tsx
│ │ ├── ObjectNode.tsx
│ │ ├── ObjectNodeProperty.tsx
│ │ └── PrimitiveNode.tsx
│ ├── constants
│ │ └── root-node.constant.ts
│ ├── hooks
│ │ └── useHighlighter.ts
│ └── styles
│ │ └── handle.style.ts
├── json-editor
│ ├── components
│ │ ├── DragDropJsonFile.tsx
│ │ ├── ImportJsonModal.tsx
│ │ ├── JsonEditor.tsx
│ │ ├── JsonEditorConsole.tsx
│ │ ├── JsonValidityStatus.tsx
│ │ └── ResizableJsonEditor.tsx
│ └── hooks
│ │ └── useDragDropJsonFile.ts
├── node-detail
│ ├── array
│ │ ├── components
│ │ │ ├── ArrayInspector.tsx
│ │ │ ├── ArrayItemCard.tsx
│ │ │ ├── ArrayItemNameChip.tsx
│ │ │ ├── ArrayItemPrimitiveCard.tsx
│ │ │ └── ArrayNodeDetail.tsx
│ │ └── helpers
│ │ │ └── node-type.helper.ts
│ ├── components
│ │ ├── DataTypeText.tsx
│ │ ├── DetailArray.tsx
│ │ ├── DetailObject.tsx
│ │ ├── DetailPrimitive.tsx
│ │ ├── EmptyNodeMessage.tsx
│ │ ├── NodeDetailCard.tsx
│ │ ├── NodeDetailChip.tsx
│ │ ├── NodeDetailList.tsx
│ │ ├── NodeDetailPanel.tsx
│ │ └── NodeDetailPanelHeader.tsx
│ ├── hooks
│ │ └── useNodePath.ts
│ ├── object
│ │ ├── components
│ │ │ ├── InferredDataTypeText.tsx
│ │ │ ├── InferredDetailCard.tsx
│ │ │ ├── InferredLatLngMapCard.tsx
│ │ │ ├── LeafletMap.tsx
│ │ │ ├── ObjectNodeDetail.tsx
│ │ │ ├── PropertyCard.tsx
│ │ │ └── PropertyKeyChip.tsx
│ │ ├── enums
│ │ │ └── inferred-data-type.enum.ts
│ │ └── helpers
│ │ │ └── infer-map.helper.ts
│ └── primitive
│ │ ├── components
│ │ ├── AudioViewer.tsx
│ │ ├── Calendar.tsx
│ │ ├── ImageViewer.tsx
│ │ ├── LinkViewer.tsx
│ │ ├── MIMETypeAndSize.tsx
│ │ ├── MediaViewerBox.tsx
│ │ ├── NumberInspector.tsx
│ │ ├── PreviewAudio.tsx
│ │ ├── PreviewAudioUri.tsx
│ │ ├── PreviewColor.tsx
│ │ ├── PreviewDatetime.tsx
│ │ ├── PreviewHttpUri.tsx
│ │ ├── PreviewImage.tsx
│ │ ├── PreviewImageUri.tsx
│ │ ├── PreviewOgMeta.tsx
│ │ ├── PreviewVideo.tsx
│ │ ├── PreviewVideoUri.tsx
│ │ ├── PrimitiveNodeDetail.tsx
│ │ ├── PropertyValueTable.tsx
│ │ ├── RelativeTimeFormatter.tsx
│ │ ├── StringInspector.tsx
│ │ ├── TextCopyBox.tsx
│ │ ├── UriTable.tsx
│ │ └── VideoViewer.tsx
│ │ ├── constants
│ │ └── string-subtype.constant.ts
│ │ ├── enums
│ │ └── string-subtype.enum.ts
│ │ ├── helpers
│ │ └── string-subtype.helper.ts
│ │ ├── hooks
│ │ └── useIntlNumberFormat.ts
│ │ └── types
│ │ ├── http-uri.type.ts
│ │ └── media-src.type.ts
├── services
│ └── local-storage.service.ts
├── settings
│ └── components
│ │ └── SettingsModal.tsx
├── store
│ ├── json-diagram-view
│ │ └── json-diagram-view.store.ts
│ ├── json-editor-view
│ │ └── json-editor-view.store.ts
│ ├── json-engine
│ │ ├── enums
│ │ │ ├── edge-type.enum.ts
│ │ │ ├── json-data-type.enum.ts
│ │ │ └── node-type.enum.ts
│ │ ├── helpers
│ │ │ ├── json-data-type.helper.ts
│ │ │ ├── json-engine.helper.ts
│ │ │ ├── json-parser.helper.ts
│ │ │ ├── sea-node-position.helper.ts
│ │ │ └── sea-node.helper.ts
│ │ ├── json-engine.constant.ts
│ │ ├── json-engine.store.ts
│ │ └── types
│ │ │ └── sea-node.type.ts
│ ├── landing
│ │ └── landing.store.ts
│ ├── node-detail-view
│ │ ├── hooks
│ │ │ └── useHoverNodeDetails.ts
│ │ └── node-detail-view.store.ts
│ └── settings
│ │ └── settings.store.ts
├── ui
│ ├── components
│ │ ├── BooleanChip.tsx
│ │ ├── CircleTransparentButton.tsx
│ │ ├── NullChip.tsx
│ │ └── Text.tsx
│ ├── constants
│ │ └── sizes.constant.ts
│ ├── hooks
│ │ └── useJsonSeaRecommendedWidth.ts
│ └── icon
│ │ ├── Icon.tsx
│ │ ├── icon.helper.ts
│ │ └── icon.type.ts
└── utils
│ ├── array.util.ts
│ ├── file-download.util.ts
│ ├── function.util.ts
│ ├── json.util.ts
│ ├── object.util.ts
│ ├── react-hooks
│ ├── useBoolean.ts
│ ├── useCopyToClipboard.ts
│ ├── useCustomTheme.ts
│ ├── useEnv.ts
│ ├── useHover.ts
│ ├── useIntervallyForceUpdate.ts
│ ├── useIsMounted.ts
│ ├── useSimpleFetch.ts
│ └── useString.ts
│ ├── string.util.ts
│ └── window.util.ts
├── tailwind.config.js
├── tsconfig.json
└── yarn.lock
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.yml:
--------------------------------------------------------------------------------
1 | name: '🐞 Bug report'
2 | title: '[BUG] TITLE_HERE_REPLACE_ME'
3 | description: Create a report to help us improve
4 | labels: [bug]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thank you for reporting a bug 🙏
10 |
11 | - type: textarea
12 | id: description
13 | attributes:
14 | label: Describe the bug
15 | description: Provide a clear and concise description of the challenge you are running into.
16 | validations:
17 | required: true
18 |
19 | - type: textarea
20 | id: steps
21 | attributes:
22 | label: Steps to reproduce the bug
23 | description: Describe the steps we have to take to reproduce the behavior.
24 | placeholder: |
25 | 1. Go to '...'
26 | 2. Click on '....'
27 | 3. Scroll down to '....'
28 | 4. See error
29 | validations:
30 | required: true
31 |
32 | - type: textarea
33 | id: screenshots_or_video
34 | attributes:
35 | label: Screenshots or video
36 | description: |
37 | If applicable, inlude screenshots or video to help explain your problem.
38 | placeholder: |
39 | You can drag your video or image files inside of this editor ↓
40 |
41 | - type: input
42 | id: os
43 | attributes:
44 | label: Operating system
45 | description: What operating system are you using?
46 | placeholder: |
47 | - OS: [e.g. macOS, Windows, Linux]
48 | validations:
49 | required: true
50 |
51 | - type: dropdown
52 | id: browser_type
53 | attributes:
54 | label: Browser
55 | description: Select the browser where the bug can be reproduced.
56 | options:
57 | - 'Chrome'
58 | - 'Safari'
59 | - 'Firefox'
60 | - 'Edge'
61 | - 'Opera'
62 | - 'Others'
63 | validations:
64 | required: true
65 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature-request.yml:
--------------------------------------------------------------------------------
1 | name: '💡 Feature request'
2 | title: '[FEATURE] TITLE_HERE_REPLACE_ME'
3 | description: Request a new feature.
4 | labels: [feature]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thank you for requesting a feature 👍
10 |
11 | - type: textarea
12 | id: description
13 | attributes:
14 | label: Describe the feature
15 | description: Provide a clear and concise description of the desired feature.
16 | validations:
17 | required: true
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib",
3 | "typescript.enablePromptUseWorkspaceTsdk": true
4 | }
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Altenull
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # JSON Sea
2 |
3 |
4 |
5 |
6 |
7 | 🌊 Dive deep into the JSON Sea!
8 |
9 | ## 🌊 Key Features
10 |
11 | - Quickly understand JSON data structure by visualizing it as a graph.
12 | - Provides useful insights into your JSON data.
13 | - Import JSON via URL or File.
14 | - Support Light/Dark mode.
15 |
16 | ## ⭐️ Show Your Support
17 |
18 | Please give a ⭐️ if this project helped you!
19 |
20 | ## 👏 Contributing
21 |
22 | Contributions are always welcome!
23 |
24 | If you have any requests or find a bug, please open a new [Issue](https://github.com/altenull/json-sea/issues) on Github.
25 |
26 | ## 📝 License
27 |
28 | Licensed under the [MIT](./LICENSE).
29 |
--------------------------------------------------------------------------------
/app/analytics-wrapper.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Analytics } from '@vercel/analytics/react';
4 |
5 | /**
6 | * https://vercel.com/docs/concepts/analytics/audiences/quickstart
7 | */
8 | const AnalyticsWrapper = () => {
9 | return ;
10 | };
11 |
12 | export default AnalyticsWrapper;
13 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altenull/json-sea/ec6f55e84a1e68c5d6fce8835bebabe50c437095/app/favicon.ico
--------------------------------------------------------------------------------
/app/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer components {
6 | /* json-diagram */
7 | .node-shell-base {
8 | @apply relative flex max-w-nodeMaxWidth flex-col border-2 border-solid border-default-200 bg-background py-3 pl-3 hover:border-default-300;
9 | }
10 | .node-shell-object {
11 | @apply min-w-nodeMinWidth rounded-bl-3xl rounded-tl-3xl py-3 pl-3;
12 | }
13 | .node-shell-array {
14 | @apply max-h-arrayNodeSize min-h-arrayNodeSize min-w-arrayNodeSize max-w-arrayNodeSize rounded-full p-0;
15 | }
16 | .node-shell-primitive {
17 | @apply min-w-nodeMinWidth rounded-bl-full rounded-tl-full py-nodePadding pl-nodePadding;
18 | }
19 | /**
20 | * Set `rx` and `ry` instead of `border-radius`.
21 | * The `border-radius` is not supported in `` tag.
22 | */
23 | .minimap-node-object {
24 | rx: 8;
25 | ry: 8;
26 | }
27 | .minimap-node-array {
28 | /* arrayNodeSize(64) / 2 is 32 */
29 | rx: 32;
30 | ry: 32;
31 | }
32 | .minimap-node-primitive {
33 | rx: 8;
34 | ry: 8;
35 | }
36 | .react-flow-controls-button {
37 | @apply border-x-1 border-y-0 border-solid border-border bg-background first:rounded-tl-md first:rounded-tr-md first:border-y-1 last:rounded-bl-md last:rounded-br-md last:border-y-1 hover:bg-default-100;
38 | }
39 | .react-flow-controls-button-icon {
40 | @apply fill-default-700 stroke-default-700;
41 | }
42 |
43 | /* json-editor */
44 | .json-dropzone-base {
45 | @apply flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-border hover:bg-default-100;
46 | }
47 |
48 | /* node-detail-panel */
49 | .node-detail-panel {
50 | @apply flex min-h-full min-w-nodeDetailPanelWidth max-w-nodeDetailPanelWidth flex-col overflow-auto border-l-1 border-solid border-l-border bg-cyan-50 p-4 dark:bg-cyan-900;
51 | }
52 | .leaflet-zoom-button-group {
53 | @apply !border-1 border-solid !border-border shadow-none;
54 | }
55 | .leaflet-zoom-button {
56 | @apply !h-6 !w-6 bg-background !leading-5 text-default-700 first:border-b-1 first:border-solid first:border-b-border hover:bg-default-100;
57 | }
58 | .leaflet-dark-tile {
59 | /* [Leaflet Darkmode] https://blog.jamie.holdings/2022/05/15/dark-mode-for/ */
60 | filter: brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) saturate(0.3) brightness(0.7);
61 | }
62 | .calendar-day {
63 | @apply inline-flex h-7 w-7 items-center justify-center text-center text-xs text-default-600;
64 | }
65 |
66 | /* shared */
67 | .blue-dot-for-node {
68 | @apply absolute right-0 top-1/2 min-h-[10px] min-w-[10px] -translate-y-1/2 translate-x-[70%] rounded-full bg-blue-600;
69 | }
70 | .blue-dot-for-card {
71 | @apply absolute right-0 top-[24px] min-h-[40px] min-w-[40px] translate-x-1/2 rounded-full bg-blue-600/40;
72 | }
73 | }
74 |
75 | /**
76 | * To remove attribution('React Flow' title under controls), need some money.
77 | * @see https://reactflow.dev/docs/guides/remove-attribution/
78 | */
79 | .react-flow__attribution {
80 | background: transparent !important;
81 | }
82 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Inter } from 'next/font/google';
2 | import AnalyticsWrapper from './analytics-wrapper';
3 | import Providers from './providers';
4 |
5 | type Props = {
6 | children: React.ReactNode;
7 | };
8 |
9 | // If loading a variable font, you don't need to specify the font weight
10 | const inter = Inter({ subsets: ['latin'] });
11 |
12 | const RootLayout = ({ children }: Props) => {
13 | return (
14 |
15 |
16 | {children}
17 |
18 |
19 |
20 | );
21 | };
22 |
23 | export default RootLayout;
24 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from 'next';
2 | import { GlobalNav } from '../src/foundation/components/GlobalNav';
3 | import { LandingLoader } from '../src/foundation/components/LandingLoader';
4 | import { LocalhostChip } from '../src/foundation/components/LocalhostChip';
5 | import { Main } from '../src/foundation/components/Main';
6 | import { JsonDiagram } from '../src/json-diagram/components/JsonDiagram';
7 | import { ResizableJsonEditor } from '../src/json-editor/components/ResizableJsonEditor';
8 | import { NodeDetailPanel } from '../src/node-detail/components/NodeDetailPanel';
9 | import './index.css';
10 |
11 | const TITLE = 'JSON Sea';
12 | const DESCRIPTION = '🌊 Dive deep into the JSON Sea!';
13 | const OG_IMAGE_URL = 'https://raw.githubusercontent.com/altenull/json-sea/main/public/og-image.png';
14 |
15 | export const metadata: Metadata = {
16 | title: TITLE,
17 | description: DESCRIPTION,
18 | openGraph: {
19 | title: TITLE,
20 | description: DESCRIPTION,
21 | url: 'https://jsonsea.com',
22 | siteName: 'JSON SEA',
23 | images: [
24 | {
25 | url: OG_IMAGE_URL,
26 | width: 1200,
27 | height: 630,
28 | },
29 | ],
30 | locale: 'en_US',
31 | type: 'website',
32 | },
33 | twitter: {
34 | card: 'summary',
35 | title: TITLE,
36 | description: DESCRIPTION,
37 | images: [OG_IMAGE_URL],
38 | },
39 | };
40 |
41 | const RootPage = () => {
42 | return (
43 | <>
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | >
56 | );
57 | };
58 |
59 | export default RootPage;
60 |
--------------------------------------------------------------------------------
/app/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { NextUIProvider } from '@nextui-org/system';
4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5 | import { ThemeProvider as NextThemesProvider } from 'next-themes';
6 |
7 | type Props = {
8 | children: React.ReactNode;
9 | };
10 |
11 | const queryClient = new QueryClient({
12 | defaultOptions: {
13 | queries: {
14 | refetchOnWindowFocus: false,
15 | retry: false,
16 | staleTime: 1000 * 20, // 20 seoncds
17 | gcTime: 1000 * 60 * 5, // 5 minutes
18 | },
19 | },
20 | });
21 |
22 | const Providers = ({ children }: Props) => {
23 | return (
24 |
25 |
26 |
27 | {children}
28 | {/* */}
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | export default Providers;
36 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | swcMinify: true,
5 | experimental: {},
6 | };
7 |
8 | module.exports = nextConfig;
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "json-sea",
3 | "version": "1.10.2",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@monaco-editor/react": "^4.6.0",
13 | "@nextui-org/button": "^2.0.22",
14 | "@nextui-org/card": "^2.0.22",
15 | "@nextui-org/chip": "^2.0.22",
16 | "@nextui-org/image": "^2.0.22",
17 | "@nextui-org/input": "^2.1.11",
18 | "@nextui-org/link": "^2.0.23",
19 | "@nextui-org/modal": "^2.0.24",
20 | "@nextui-org/navbar": "^2.0.23",
21 | "@nextui-org/progress": "^2.0.22",
22 | "@nextui-org/react": "^2.2.2",
23 | "@nextui-org/switch": "^2.0.22",
24 | "@nextui-org/system": "^2.0.11",
25 | "@nextui-org/table": "^2.0.24",
26 | "@nextui-org/theme": "^2.1.12",
27 | "@nextui-org/tooltip": "^2.0.25",
28 | "@tanstack/react-query": "^5.0.0",
29 | "@vercel/analytics": "^1.1.1",
30 | "dagre": "^0.8.5",
31 | "date-fns": "^2.30.0",
32 | "framer-motion": "^10.16.4",
33 | "html-to-image": "^1.11.11",
34 | "leaflet": "^1.9.4",
35 | "nanoid": "^5.0.2",
36 | "next": "^13.5.6",
37 | "next-themes": "^0.2.1",
38 | "pretty-bytes": "^6.1.1",
39 | "re-resizable": "^6.9.11",
40 | "react": "^18.2.0",
41 | "react-dom": "^18.2.0",
42 | "reactflow": "^11.9.4",
43 | "timeago.js": "^4.0.2",
44 | "zustand": "^4.4.5"
45 | },
46 | "devDependencies": {
47 | "@types/dagre": "^0.7.51",
48 | "@types/leaflet": "^1.9.7",
49 | "@types/node": "^20.8.7",
50 | "@types/react": "^18.2.31",
51 | "@types/react-dom": "^18.2.14",
52 | "autoprefixer": "^10.4.16",
53 | "eslint": "^8.52.0",
54 | "eslint-config-next": "^13.5.6",
55 | "postcss": "^8.4.31",
56 | "prettier": "^3.0.3",
57 | "prettier-plugin-tailwindcss": "^0.5.6",
58 | "tailwindcss": "^3.3.5",
59 | "typescript": "^5.2.2"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import("prettier").Config} */
2 | module.exports = {
3 | tabWidth: 2,
4 | semi: true,
5 | singleQuote: true,
6 | printWidth: 120,
7 | plugins: ['prettier-plugin-tailwindcss'],
8 | tailwindConfig: './tailwind.config.js',
9 | };
10 |
--------------------------------------------------------------------------------
/public/json-sea-logo-title.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altenull/json-sea/ec6f55e84a1e68c5d6fce8835bebabe50c437095/public/json-sea-logo-title.png
--------------------------------------------------------------------------------
/public/map-marker.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altenull/json-sea/ec6f55e84a1e68c5d6fce8835bebabe50c437095/public/map-marker.png
--------------------------------------------------------------------------------
/public/oceania.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altenull/json-sea/ec6f55e84a1e68c5d6fce8835bebabe50c437095/public/oceania.png
--------------------------------------------------------------------------------
/public/og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altenull/json-sea/ec6f55e84a1e68c5d6fce8835bebabe50c437095/public/og-image.png
--------------------------------------------------------------------------------
/public/screenshot-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altenull/json-sea/ec6f55e84a1e68c5d6fce8835bebabe50c437095/public/screenshot-dark.png
--------------------------------------------------------------------------------
/public/screenshot-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altenull/json-sea/ec6f55e84a1e68c5d6fce8835bebabe50c437095/public/screenshot-light.png
--------------------------------------------------------------------------------
/public/sea-audio.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altenull/json-sea/ec6f55e84a1e68c5d6fce8835bebabe50c437095/public/sea-audio.mp3
--------------------------------------------------------------------------------
/public/sea-image.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altenull/json-sea/ec6f55e84a1e68c5d6fce8835bebabe50c437095/public/sea-image.jpeg
--------------------------------------------------------------------------------
/public/sea-video.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/altenull/json-sea/ec6f55e84a1e68c5d6fce8835bebabe50c437095/public/sea-video.mp4
--------------------------------------------------------------------------------
/src/api/json-link-api/json-link.types.ts:
--------------------------------------------------------------------------------
1 | export type JsonLink = {
2 | title: string | undefined;
3 | description: string | undefined;
4 | images?: string[];
5 | duration: number;
6 | domain: string;
7 | url: string;
8 | };
9 |
--------------------------------------------------------------------------------
/src/api/json-link-api/useJsonLinkApi.ts:
--------------------------------------------------------------------------------
1 | import { useQuery, UseQueryResult } from '@tanstack/react-query';
2 | import { endPoint } from '../../environment';
3 | import { HttpUri } from '../../node-detail/primitive/types/http-uri.type';
4 | import { JsonLink } from './json-link.types';
5 |
6 | const queryKeys = {
7 | all: ['jsonLinks'],
8 | jsonLinkByUri: (httpUri: UseJsonLinkApiRequest['httpUri']) => [...queryKeys.all, httpUri],
9 | } as const;
10 |
11 | type UseJsonLinkApiRequest = {
12 | httpUri: HttpUri;
13 | };
14 |
15 | /**
16 | * jsonLinkIO help us to extract metadata from URL
17 | */
18 | export const useJsonLinkApi = (request: UseJsonLinkApiRequest): UseQueryResult => {
19 | async function getJsonLink(): Promise {
20 | const url = `${endPoint.jsonLinkIO}?url=${request.httpUri}`;
21 |
22 | const jsonLink: JsonLink | null = await fetch(url)
23 | .then((response) => response.json())
24 | .catch(() => null);
25 |
26 | return jsonLink;
27 | }
28 |
29 | return useQuery({ queryKey: queryKeys.jsonLinkByUri(request.httpUri), queryFn: getJsonLink });
30 | };
31 |
--------------------------------------------------------------------------------
/src/api/media-head-api/media-head.types.ts:
--------------------------------------------------------------------------------
1 | export type MediaHead = {
2 | mimeType: string | null; // 'image/png', 'video/mp4', 'audio/mp3'
3 | mimeBytes: number | null;
4 | };
5 |
--------------------------------------------------------------------------------
/src/api/media-head-api/useMediaHeadApi.ts:
--------------------------------------------------------------------------------
1 | import { useQuery, UseQueryResult } from '@tanstack/react-query';
2 | import { HttpUri } from '../../node-detail/primitive/types/http-uri.type';
3 | import {
4 | AudioSrc,
5 | Base64AudioDataUri,
6 | Base64ImageDataUri,
7 | Base64VideoDataUri,
8 | ImageSrc,
9 | VideoSrc,
10 | } from '../../node-detail/primitive/types/media-src.type';
11 | import { isString } from '../../utils/json.util';
12 | import { MediaHead } from './media-head.types';
13 |
14 | const queryKeys = {
15 | all: ['mediaHeads'],
16 | mediaHeadBySrc: (mediaSrc: UseMediaHeadApiRequest['mediaSrc']) => [...queryKeys.all, mediaSrc],
17 | } as const;
18 |
19 | type UseMediaHeadApiRequest = {
20 | mediaSrc: ImageSrc | AudioSrc | VideoSrc;
21 | };
22 |
23 | const startsWithHttpOrHttps = (v: string): v is HttpUri => v.startsWith('http:') || v.startsWith('https:');
24 |
25 | const extractBase64MediaType = (
26 | base64MediaDataUri: Base64ImageDataUri | Base64AudioDataUri | Base64VideoDataUri
27 | ): string => {
28 | const sliceEnd: number = base64MediaDataUri.indexOf(';base64');
29 |
30 | return base64MediaDataUri.slice(0, sliceEnd).replace('data:', '');
31 | };
32 |
33 | export const useMediaHeadApi = (request: UseMediaHeadApiRequest): UseQueryResult => {
34 | async function getMediaHead(): Promise {
35 | if (startsWithHttpOrHttps(request.mediaSrc)) {
36 | const url = request.mediaSrc;
37 |
38 | const mediaHead: MediaHead = await fetch(url, { method: 'HEAD' })
39 | .then((response) => {
40 | const contentType: string | null = response.headers.get('Content-Type');
41 | const contentLength: string | null = response.headers.get('Content-Length');
42 |
43 | return {
44 | mimeType: contentType,
45 | mimeBytes: isString(contentLength) ? Number(contentLength) : null,
46 | };
47 | })
48 | .catch(() => ({
49 | mimeType: null,
50 | mimeBytes: null,
51 | }));
52 |
53 | return mediaHead;
54 | } else {
55 | return {
56 | mimeType: extractBase64MediaType(request.mediaSrc),
57 | mimeBytes: null,
58 | };
59 | }
60 | }
61 |
62 | return useQuery({ queryKey: queryKeys.mediaHeadBySrc(request.mediaSrc), queryFn: getMediaHead });
63 | };
64 |
--------------------------------------------------------------------------------
/src/environment.ts:
--------------------------------------------------------------------------------
1 | export const featureFlag = {
2 | debugMode: false,
3 | nodesChange: false,
4 | };
5 |
6 | export const env = {
7 | localhost: 'localhost',
8 | };
9 |
10 | export const endPoint = {
11 | jsonLinkIO: 'https://jsonlink.io/api/extract',
12 | };
13 |
14 | export const externalLink = {
15 | jsonSeaGithubRepo: 'https://github.com/altenull/json-sea',
16 | altenullGithub: 'https://github.com/altenull',
17 | };
18 |
19 | export const assets = {
20 | ogImage: 'https://raw.githubusercontent.com/altenull/json-sea/main/public/og-image.png',
21 | // seaImage: 'https://raw.githubusercontent.com/altenull/json-sea/main/public/sea-image.jpeg',
22 | // seaVideo: 'https://raw.githubusercontent.com/altenull/json-sea/main/public/sea-video.mp4',
23 | // seaAudio: 'https://raw.githubusercontent.com/altenull/json-sea/main/public/sea-audio.mp3',
24 | };
25 |
--------------------------------------------------------------------------------
/src/foundation/components/Copyright.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@nextui-org/link';
2 | import { memo } from 'react';
3 | import { externalLink } from '../../environment';
4 | import { Text } from '../../ui/components/Text';
5 |
6 | const _Copyright = () => {
7 | const renderCreatedBy = () => (
8 | <>
9 | Created by{' '}
10 |
11 | altenull
12 |
13 | >
14 | );
15 |
16 | const renderCopyright = () => {
17 | const currentYear: number = new Date().getFullYear();
18 |
19 | return <>© {currentYear} JSON SEA>;
20 | };
21 |
22 | return (
23 |
24 | {renderCreatedBy()} · {renderCopyright()}
25 |
26 | );
27 | };
28 |
29 | export const Copyright = memo(_Copyright);
30 |
--------------------------------------------------------------------------------
/src/foundation/components/GithubButton.tsx:
--------------------------------------------------------------------------------
1 | import { semanticColors } from '@nextui-org/theme';
2 | import { memo } from 'react';
3 | import { externalLink } from '../../environment';
4 | import { CircleTransparentButton } from '../../ui/components/CircleTransparentButton';
5 | import { Icon } from '../../ui/icon/Icon';
6 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
7 | import { openLinkAsNewTab } from '../../utils/window.util';
8 |
9 | const _GithubButton = () => {
10 | const { theme } = useCustomTheme();
11 |
12 | return (
13 | openLinkAsNewTab(externalLink.jsonSeaGithubRepo)}>
14 |
15 |
16 | );
17 | };
18 |
19 | export const GithubButton = memo(_GithubButton);
20 |
--------------------------------------------------------------------------------
/src/foundation/components/GlobalNav.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Navbar, NavbarContent, NavbarItem } from '@nextui-org/navbar';
4 | import { useId } from 'react';
5 | import { GithubButton } from './GithubButton';
6 | import { JsonEditorToggle } from './JsonEditorToggle';
7 | import { JsonSeaLogoTitle } from './JsonSeaLogoTitle';
8 | import { SettingsButton } from './SettingsButton';
9 | import { ThemeToggle } from './ThemeToggle';
10 |
11 | const _GlobalNav = () => {
12 | /**
13 | * Set id prop of each `Navbar.Item` component to resolve below warning message.
14 | * @warning - Warning: Prop `id` did not match. Server: "react-aria-1" Client: "react-aria-2"
15 | */
16 | const navItemId1 = useId();
17 | const navItemId2 = useId();
18 | const navItemId3 = useId();
19 | const navItemId4 = useId();
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export const GlobalNav = _GlobalNav;
51 |
--------------------------------------------------------------------------------
/src/foundation/components/JsonEditorToggle.tsx:
--------------------------------------------------------------------------------
1 | import { semanticColors } from '@nextui-org/react';
2 | import { useJsonEditorViewStore } from '../../store/json-editor-view/json-editor-view.store';
3 | import { CircleTransparentButton } from '../../ui/components/CircleTransparentButton';
4 | import { Icon } from '../../ui/icon/Icon';
5 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
6 |
7 | const _JsonEditorToggle = () => {
8 | const [isJsonEditorVisible, toggleJsonEditor] = useJsonEditorViewStore((state) => [
9 | state.isJsonEditorVisible,
10 | state.toggleJsonEditor,
11 | ]);
12 |
13 | const { theme } = useCustomTheme();
14 |
15 | return (
16 |
17 |
22 |
23 | );
24 | };
25 |
26 | export const JsonEditorToggle = _JsonEditorToggle;
27 |
--------------------------------------------------------------------------------
/src/foundation/components/JsonSeaLogoTitle.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip } from '@nextui-org/tooltip';
2 | import { memo } from 'react';
3 | import { sizes } from '../../ui/constants/sizes.constant';
4 | import { useJsonSeaRecommendedWidth } from '../../ui/hooks/useJsonSeaRecommendedWidth';
5 |
6 | const _JsonSeaLogoTitle = () => {
7 | const { isJsonSeaRecommendedWidth } = useJsonSeaRecommendedWidth();
8 |
9 | return (
10 |
17 | We recommend diving into the JSON Sea at least {sizes.jsonSeaRecommendedWidth}px
18 | >
19 | }
20 | placement="bottom"
21 | >
22 |
99 |
100 | );
101 | };
102 |
103 | export const JsonSeaLogoTitle = memo(_JsonSeaLogoTitle);
104 |
--------------------------------------------------------------------------------
/src/foundation/components/LandingLoader.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { CSSProperties } from 'react';
4 | import { useLandingStore } from '../../store/landing/landing.store';
5 | import { Text } from '../../ui/components/Text';
6 |
7 | const _LandingLoader = () => {
8 | const isAppInitalized = useLandingStore((state) => state.isAppInitalized);
9 |
10 | if (isAppInitalized) {
11 | return null;
12 | }
13 |
14 | const waveStyle: CSSProperties = {
15 | position: 'absolute',
16 | left: '50%',
17 | minWidth: '300vw',
18 | minHeight: '300vw',
19 | backgroundColor: '#ffffff',
20 | animationName: 'wave-anim',
21 | animationIterationCount: 'infinite',
22 | animationTimingFunction: 'linear',
23 | animationDuration: '10s',
24 | };
25 |
26 | /**
27 | * Should do styling with pure CSS for better UX.
28 | */
29 | return (
30 | <>
31 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | You are diving into {`'`}JSON Sea{`'`}...
51 |
52 |
53 | >
54 | );
55 | };
56 |
57 | export const LandingLoader = _LandingLoader;
58 |
--------------------------------------------------------------------------------
/src/foundation/components/LocalhostChip.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Chip } from '@nextui-org/chip';
4 | import { memo } from 'react';
5 | import { useEnv } from '../../utils/react-hooks/useEnv';
6 |
7 | const _LocalhostChip = () => {
8 | const { isLocalhost } = useEnv();
9 |
10 | if (!isLocalhost) {
11 | return null;
12 | }
13 |
14 | return (
15 |
16 | Localhost
17 |
18 | );
19 | };
20 |
21 | export const LocalhostChip = memo(_LocalhostChip);
22 |
--------------------------------------------------------------------------------
/src/foundation/components/Main.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | children: React.ReactNode;
3 | };
4 |
5 | const _Main = ({ children }: Props) => {
6 | // 3.375rem is height of ``
7 | return {children};
8 | };
9 |
10 | export const Main = _Main;
11 |
--------------------------------------------------------------------------------
/src/foundation/components/SettingsButton.tsx:
--------------------------------------------------------------------------------
1 | import { useDisclosure } from '@nextui-org/modal';
2 | import { semanticColors } from '@nextui-org/react';
3 | import { memo } from 'react';
4 | import { SettingsModal } from '../../settings/components/SettingsModal';
5 | import { CircleTransparentButton } from '../../ui/components/CircleTransparentButton';
6 | import { Icon } from '../../ui/icon/Icon';
7 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
8 |
9 | const _SettingsButton = () => {
10 | const { theme } = useCustomTheme();
11 | const { isOpen: isSettingsModalOpen, onOpen: openSettingsModal, onClose: closeSettingsModal } = useDisclosure();
12 |
13 | return (
14 | <>
15 |
16 |
17 |
18 |
19 |
20 | >
21 | );
22 | };
23 |
24 | export const SettingsButton = memo(_SettingsButton);
25 |
--------------------------------------------------------------------------------
/src/foundation/components/ThemeToggle.tsx:
--------------------------------------------------------------------------------
1 | import { semanticColors } from '@nextui-org/react';
2 | import { CircleTransparentButton } from '../../ui/components/CircleTransparentButton';
3 | import { Icon } from '../../ui/icon/Icon';
4 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
5 |
6 | const _ThemeToggle = () => {
7 | const { theme, isDarkMode, setTheme } = useCustomTheme();
8 |
9 | return (
10 | setTheme(isDarkMode ? 'light' : 'dark')}>
11 |
12 |
13 | );
14 | };
15 |
16 | export const ThemeToggle = _ThemeToggle;
17 |
--------------------------------------------------------------------------------
/src/json-diagram/components/ArrayNode.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { NodeProps } from 'reactflow';
3 | import { NodeType } from '../../store/json-engine/enums/node-type.enum';
4 | import { addPrefixChain } from '../../store/json-engine/helpers/json-parser.helper';
5 | import { ArrayNodeData } from '../../store/json-engine/types/sea-node.type';
6 | import { useNodeDetailViewStore } from '../../store/node-detail-view/node-detail-view.store';
7 | import { Text } from '../../ui/components/Text';
8 | import { isEmptyArray } from '../../utils/array.util';
9 | import { encloseSquareBrackets } from '../../utils/string.util';
10 | import { ROOT_NODE_NAME } from '../constants/root-node.constant';
11 | import { useHighlighter } from '../hooks/useHighlighter';
12 | import { ChainHandle } from './ChainHandle';
13 | import { DefaultHandle } from './DefaultHandle';
14 | import { HoveringBlueDot } from './HoveringBlueDot';
15 | import { NodeShell } from './NodeShell';
16 |
17 | /**
18 | * ArrayNode `` Details
19 | *
20 | * source: can have if array includes at least one item.
21 | * target: always have except for RootNode.
22 | */
23 | const _ArrayNode = ({ id, data }: NodeProps) => {
24 | const hoveredNodeDetails = useNodeDetailViewStore((state) => state.hoveredNodeDetails);
25 | const { isHighlightNode } = useHighlighter();
26 |
27 | const { arrayIndex, items, isRootNode } = data;
28 |
29 | const isHoveredFromNodeDetail: boolean = hoveredNodeDetails.some(({ nodeId }) => nodeId === id);
30 |
31 | return (
32 |
33 | {!isRootNode && }
34 |
35 |
36 | {isRootNode ? ROOT_NODE_NAME : encloseSquareBrackets(arrayIndex)}
37 |
38 | {!isEmptyArray(items) && }
39 |
40 | {isHoveredFromNodeDetail && }
41 |
42 |
43 | );
44 | };
45 |
46 | export const ArrayNode = memo(_ArrayNode);
47 |
--------------------------------------------------------------------------------
/src/json-diagram/components/ChainEdge.tsx:
--------------------------------------------------------------------------------
1 | import { semanticColors } from '@nextui-org/react';
2 | import { memo } from 'react';
3 | import { EdgeProps, getStraightPath } from 'reactflow';
4 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
5 |
6 | const _ChainEdge = ({ id, sourceX, sourceY, targetX, targetY, style = {}, markerEnd }: EdgeProps) => {
7 | const { theme } = useCustomTheme();
8 | const [edgePath] = getStraightPath({
9 | sourceX,
10 | sourceY,
11 | targetX,
12 | targetY,
13 | });
14 |
15 | const strokeWidth = 3;
16 |
17 | return (
18 |
31 | );
32 | };
33 |
34 | /**
35 | * @reference https://reactflow.dev/docs/examples/edges/custom-edge/
36 | */
37 | export const ChainEdge = memo(_ChainEdge);
38 |
--------------------------------------------------------------------------------
/src/json-diagram/components/ChainHandle.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useMemo } from 'react';
2 | import { Handle, HandleProps, HandleType, Position } from 'reactflow';
3 | import { sizes } from '../../ui/constants/sizes.constant';
4 | import { hiddenHandleStyle } from '../styles/handle.style';
5 |
6 | type Props = Pick;
7 |
8 | const _ChainHandle = ({ id, type }: Props) => {
9 | const handleTypeToPositionMap: Record = useMemo(
10 | () => ({
11 | source: Position.Bottom,
12 | target: Position.Top,
13 | }),
14 | []
15 | );
16 |
17 | return (
18 |
24 | );
25 | };
26 |
27 | export const ChainHandle = memo(_ChainHandle);
28 |
--------------------------------------------------------------------------------
/src/json-diagram/components/CustomMiniMap.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, ComponentProps, memo, useCallback, useMemo } from 'react';
2 | import { GetMiniMapNodeAttribute, MiniMap } from 'reactflow';
3 | import { NodeType } from '../../store/json-engine/enums/node-type.enum';
4 | import { ArrayNodeData, ObjectNodeData, PrimitiveNodeData } from '../../store/json-engine/types/sea-node.type';
5 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
6 |
7 | type MinimapTheme = {
8 | backgroundColor: CSSProperties['backgroundColor'];
9 | maskColor: ComponentProps['maskColor'];
10 | };
11 |
12 | const _CustomMiniMap = () => {
13 | const { isDarkMode } = useCustomTheme();
14 |
15 | const nodeClassName: GetMiniMapNodeAttribute = useCallback(
16 | (node) => {
17 | const nodeTypeToClassNameMap: Record = {
18 | [NodeType.Object]: 'object-node',
19 | [NodeType.Array]: 'array-node',
20 | [NodeType.Primitive]: 'primitive-node',
21 | };
22 |
23 | return nodeTypeToClassNameMap[node.type as NodeType];
24 | },
25 | [],
26 | );
27 |
28 | const minimapTheme = useMemo(() => {
29 | const lightMinimapTheme: MinimapTheme = {
30 | backgroundColor: '#ffffff', // backgroundContrast
31 | maskColor: undefined,
32 | };
33 | const darkMinimapTheme: MinimapTheme = {
34 | backgroundColor: '#16181A', // backgroundContrast
35 | maskColor: 'rgba(15, 15, 15, 0.7)',
36 | };
37 |
38 | return isDarkMode ? darkMinimapTheme : lightMinimapTheme;
39 | }, [isDarkMode]);
40 |
41 | return (
42 |
53 | );
54 | };
55 |
56 | export const CustomMiniMap = memo(_CustomMiniMap);
57 |
--------------------------------------------------------------------------------
/src/json-diagram/components/DefaultEdge.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { semanticColors } from '@nextui-org/react';
4 | import { memo } from 'react';
5 | import { EdgeProps, getBezierPath } from 'reactflow';
6 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
7 | import { useHighlighter } from '../hooks/useHighlighter';
8 |
9 | const _DefaultEdge = ({
10 | id,
11 | sourceX,
12 | sourceY,
13 | targetX,
14 | targetY,
15 | sourcePosition,
16 | targetPosition,
17 | style = {},
18 | markerEnd,
19 | source,
20 | /**
21 | * [2023-02-01] `sourceHandle` property seems to be transformed to `sourceHandleId` internally.
22 | * [2023-07-05] It seems that `sourceHandle` and `sourceHandleId` are same.
23 | */
24 | sourceHandleId,
25 | target,
26 | }: EdgeProps) => {
27 | const { theme } = useCustomTheme();
28 | const { isHighlightEdge } = useHighlighter();
29 |
30 | const [edgePath] = getBezierPath({
31 | sourceX,
32 | sourceY,
33 | sourcePosition,
34 | targetX,
35 | targetY,
36 | targetPosition,
37 | });
38 |
39 | const dynamicStyle = isHighlightEdge(id)
40 | ? {
41 | ...style,
42 | stroke: (semanticColors[theme].primary as any).DEFAULT,
43 | strokeWidth: 3,
44 | }
45 | : style;
46 |
47 | return ;
48 | };
49 |
50 | /**
51 | * @reference https://reactflow.dev/docs/examples/edges/custom-edge/
52 | */
53 | export const DefaultEdge = memo(_DefaultEdge);
54 |
--------------------------------------------------------------------------------
/src/json-diagram/components/DefaultHandle.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useMemo } from 'react';
2 | import { Handle, HandleProps, HandleType, Position } from 'reactflow';
3 | import { hiddenHandleStyle } from '../styles/handle.style';
4 |
5 | type Props = Pick & {
6 | style?: React.CSSProperties;
7 | };
8 |
9 | const _DefaultHandle = ({ id, type, style = {} }: Props) => {
10 | const handleTypeToPositionMap: Record = useMemo(
11 | () => ({
12 | source: Position.Right,
13 | target: Position.Left,
14 | }),
15 | []
16 | );
17 |
18 | return (
19 |
20 | );
21 | };
22 |
23 | export const DefaultHandle = memo(_DefaultHandle);
24 |
--------------------------------------------------------------------------------
/src/json-diagram/components/DownloadImageButton.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress } from '@nextui-org/progress';
2 | import { semanticColors } from '@nextui-org/react';
3 | import { memo, useCallback } from 'react';
4 | import { CircleTransparentButton } from '../../ui/components/CircleTransparentButton';
5 | import { Icon } from '../../ui/icon/Icon';
6 | import { downloadAsFile } from '../../utils/file-download.util';
7 | import { useBoolean } from '../../utils/react-hooks/useBoolean';
8 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
9 |
10 | const _DownloadImageButton = () => {
11 | const { theme } = useCustomTheme();
12 | const { bool: isDownloading, setTrue: startDownload, setFalse: stopDownload } = useBoolean();
13 |
14 | const SELF_CLASSNAME = 'download-image-button';
15 |
16 | const handleClick = useCallback(async () => {
17 | startDownload();
18 |
19 | const { toPng } = await import('html-to-image');
20 | toPng(document.querySelector('.react-flow') as HTMLElement, {
21 | filter: (node) => {
22 | // we don't want to add the minimap, controls and download image button to the image
23 | const filterTargetTokens = ['react-flow__minimap', 'react-flow__controls', SELF_CLASSNAME];
24 |
25 | const isFilterTargetToken: boolean = filterTargetTokens.some(
26 | (token: string) => node?.classList?.contains(token),
27 | );
28 |
29 | return !isFilterTargetToken;
30 | },
31 | })
32 | .then((dataUrl: string) => downloadAsFile(dataUrl, 'json-sea.png'))
33 | .finally(() => stopDownload());
34 | }, [startDownload, stopDownload]);
35 |
36 | return (
37 |
47 | {isDownloading ? (
48 |
58 | ) : (
59 |
60 | )}
61 |
62 | );
63 | };
64 |
65 | /**
66 | * @see https://reactflow.dev/docs/examples/misc/download-image/
67 | */
68 | export const DownloadImageButton = memo(_DownloadImageButton);
69 |
--------------------------------------------------------------------------------
/src/json-diagram/components/FitViewInvoker.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useEffect } from 'react';
2 | import { Node, useReactFlow } from 'reactflow';
3 |
4 | type Props = {
5 | seaNodes: Node[];
6 | };
7 |
8 | const _FitViewInvoker = ({ seaNodes }: Props) => {
9 | const reactFlowInstance = useReactFlow();
10 |
11 | useEffect(() => {
12 | reactFlowInstance.fitView();
13 | reactFlowInstance.zoomOut();
14 | }, [reactFlowInstance, seaNodes]);
15 |
16 | return <>>;
17 | };
18 |
19 | /**
20 | * The reason I put this component is that useReactFlow hook can only be callable under `ReactFlow`.
21 | */
22 | export const FitViewInvoker = memo(_FitViewInvoker);
23 |
--------------------------------------------------------------------------------
/src/json-diagram/components/HoveringBlueDot.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 |
3 | const _HoveringBlueDot = () => {
4 | return ;
5 | };
6 |
7 | export const HoveringBlueDot = memo(_HoveringBlueDot);
8 |
--------------------------------------------------------------------------------
/src/json-diagram/components/JsonDiagram.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useCallback, useEffect } from 'react';
4 | import ReactFlow, {
5 | Background,
6 | BackgroundVariant,
7 | Controls,
8 | EdgeTypes,
9 | NodeChange,
10 | NodeTypes,
11 | applyNodeChanges,
12 | useEdgesState,
13 | useNodesState,
14 | } from 'reactflow';
15 | import 'reactflow/dist/style.css';
16 | import { featureFlag } from '../../environment';
17 | import { useJsonDiagramViewStore } from '../../store/json-diagram-view/json-diagram-view.store';
18 | import { EdgeType } from '../../store/json-engine/enums/edge-type.enum';
19 | import { NodeType } from '../../store/json-engine/enums/node-type.enum';
20 | import { useJsonEngineStore } from '../../store/json-engine/json-engine.store';
21 | import { useLandingStore } from '../../store/landing/landing.store';
22 | import { useSettingsStore } from '../../store/settings/settings.store';
23 | import { useIsMounted } from '../../utils/react-hooks/useIsMounted';
24 | import { ArrayNode } from './ArrayNode';
25 | import { ChainEdge } from './ChainEdge';
26 | import { CustomMiniMap } from './CustomMiniMap';
27 | import { DefaultEdge } from './DefaultEdge';
28 | import { DownloadImageButton } from './DownloadImageButton';
29 | import { FitViewInvoker } from './FitViewInvoker';
30 | import { ObjectNode } from './ObjectNode';
31 | import { PrimitiveNode } from './PrimitiveNode';
32 |
33 | const nodeTypes: NodeTypes = {
34 | [NodeType.Object]: ObjectNode,
35 | [NodeType.Array]: ArrayNode,
36 | [NodeType.Primitive]: PrimitiveNode,
37 | };
38 |
39 | const edgeTypes: EdgeTypes = {
40 | [EdgeType.Default]: DefaultEdge,
41 | [EdgeType.Chain]: ChainEdge,
42 | };
43 |
44 | const _JsonDiagram = () => {
45 | const [seaNodes, setSeaNodes] = useNodesState([]);
46 | const [edges, setEdges] = useEdgesState([]);
47 |
48 | const isMinimapOn = useSettingsStore((state) => state.isMinimapOn);
49 | const jsonTree = useJsonEngineStore((state) => state.jsonTree);
50 | const initApp = useLandingStore((state) => state.initApp);
51 | const selectNode = useJsonDiagramViewStore((state) => state.selectNode);
52 |
53 | const isMounted = useIsMounted();
54 |
55 | useEffect(() => {
56 | const { seaNodes, edges } = jsonTree;
57 |
58 | setSeaNodes(seaNodes);
59 | setEdges(edges);
60 |
61 | if (seaNodes.length > 0) {
62 | selectNode(seaNodes[0].id);
63 | }
64 | }, [jsonTree, selectNode, setSeaNodes, setEdges]);
65 |
66 | const handleNodesChange = useCallback(
67 | (changes: NodeChange[]) => setSeaNodes((nds) => applyNodeChanges(changes, nds)),
68 | [setSeaNodes],
69 | );
70 |
71 | return (
72 | /**
73 | * Please refer `nodeClassName` prop of `` in ``, If you wonder the class names(object-node, array-node, ...)
74 | */
75 |
76 | {isMounted && (
77 |
91 | {isMinimapOn && }
92 |
97 |
98 |
99 |
100 |
101 | )}
102 |
103 | );
104 | };
105 |
106 | export const JsonDiagram = _JsonDiagram;
107 |
--------------------------------------------------------------------------------
/src/json-diagram/components/NodeShell.tsx:
--------------------------------------------------------------------------------
1 | import { Chip } from '@nextui-org/chip';
2 | import { semanticColors } from '@nextui-org/theme';
3 | import { nodeTypeToAcronymMap } from '../../node-detail/array/helpers/node-type.helper';
4 | import { useNodePath } from '../../node-detail/hooks/useNodePath';
5 | import { useJsonDiagramViewStore } from '../../store/json-diagram-view/json-diagram-view.store';
6 | import { NodeType } from '../../store/json-engine/enums/node-type.enum';
7 | import { useSettingsStore } from '../../store/settings/settings.store';
8 | import { Text } from '../../ui/components/Text';
9 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
10 | import { useEnv } from '../../utils/react-hooks/useEnv';
11 |
12 | type Props = {
13 | nodeId: string;
14 | nodeType: NodeType;
15 | isHighlight: boolean;
16 | children: React.ReactNode;
17 | };
18 |
19 | const hostClassNames: Record = {
20 | [NodeType.Object]: 'group node-shell-base node-shell-object',
21 | [NodeType.Array]: 'group node-shell-base node-shell-array',
22 | [NodeType.Primitive]: 'group node-shell-base node-shell-primitive',
23 | };
24 |
25 | const _NodeShell = ({ nodeId, nodeType, isHighlight, children }: Props) => {
26 | const [selectedNodeId, selectNode] = useJsonDiagramViewStore((state) => [state.selectedNodeId, state.selectNode]);
27 | const isNodePathOn = useSettingsStore((state) => state.isNodePathOn);
28 |
29 | const { fullNodePath } = useNodePath(nodeId);
30 | const { theme } = useCustomTheme();
31 | const { isLocalhost } = useEnv();
32 |
33 | const isSelected = nodeId === selectedNodeId;
34 |
35 | return (
36 | selectNode(nodeId)}
43 | >
44 | {isLocalhost && (
45 |
46 | {nodeTypeToAcronymMap[nodeType]} ({nodeId})
47 |
48 | )}
49 |
50 | {nodeType === NodeType.Object && (
51 |
57 | )}
58 |
59 | {children}
60 |
61 | {isNodePathOn && (
62 |
68 | {fullNodePath}
69 |
70 | )}
71 |
72 | );
73 | };
74 |
75 | export const NodeShell = _NodeShell;
76 |
--------------------------------------------------------------------------------
/src/json-diagram/components/ObjectNode.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useCallback } from 'react';
2 | import { NodeProps, useEdges } from 'reactflow';
3 | import { NodeType } from '../../store/json-engine/enums/node-type.enum';
4 | import { addPrefixChain } from '../../store/json-engine/helpers/json-parser.helper';
5 | import { ObjectNodeData } from '../../store/json-engine/types/sea-node.type';
6 | import { useNodeDetailViewStore } from '../../store/node-detail-view/node-detail-view.store';
7 | import { useHighlighter } from '../hooks/useHighlighter';
8 | import { ChainHandle } from './ChainHandle';
9 | import { DefaultHandle } from './DefaultHandle';
10 | import { HoveringBlueDot } from './HoveringBlueDot';
11 | import { NodeShell } from './NodeShell';
12 | import { ObjectNodeProperty } from './ObjectNodeProperty';
13 |
14 | /**
15 | * ObjectNode `` Details
16 | *
17 | * source: impossible to have.
18 | * target: always have except for RootNode.
19 | */
20 | const _ObjectNode = ({ id, data }: NodeProps) => {
21 | const hoveredNodeDetails = useNodeDetailViewStore((state) => state.hoveredNodeDetails);
22 | const { isHighlightNode } = useHighlighter();
23 | const edges = useEdges();
24 |
25 | const { obj, isRootNode } = data;
26 |
27 | const renderProperties = useCallback(() => {
28 | return Object.entries(obj).map(([propertyK, propertyV]) => {
29 | const hasChildNode: boolean = edges.some(
30 | ({ source, sourceHandle }) => source === id && sourceHandle === propertyK,
31 | );
32 |
33 | return (
34 |
41 | );
42 | });
43 | }, [obj, edges, id]);
44 |
45 | /**
46 | * undefined `propertyK` means a `ArrayItemCard` is hovered, not `PropertyCard`.
47 | */
48 | const isHoveredFromNodeDetail: boolean = hoveredNodeDetails.some(
49 | ({ nodeId, propertyK }) => nodeId === id && propertyK === undefined,
50 | );
51 |
52 | return (
53 |
54 |
55 | {!isRootNode && }
56 |
57 | {renderProperties()}
58 |
59 | {isHoveredFromNodeDetail && }
60 |
61 |
62 | );
63 | };
64 |
65 | export const ObjectNode = memo(_ObjectNode);
66 |
--------------------------------------------------------------------------------
/src/json-diagram/components/ObjectNodeProperty.tsx:
--------------------------------------------------------------------------------
1 | import { semanticColors } from '@nextui-org/react';
2 | import { memo } from 'react';
3 | import { validateJsonDataType } from '../../store/json-engine/helpers/json-data-type.helper';
4 | import { useNodeDetailViewStore } from '../../store/node-detail-view/node-detail-view.store';
5 | import { BooleanChip } from '../../ui/components/BooleanChip';
6 | import { NullChip } from '../../ui/components/NullChip';
7 | import { Text } from '../../ui/components/Text';
8 | import { Icon } from '../../ui/icon/Icon';
9 | import { isEmptyArray } from '../../utils/array.util';
10 | import { isEmptyObject } from '../../utils/object.util';
11 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
12 | import { DefaultHandle } from './DefaultHandle';
13 | import { HoveringBlueDot } from './HoveringBlueDot';
14 |
15 | type Props = {
16 | nodeId: string;
17 | propertyK: string;
18 | propertyV: any;
19 | hasChildNode: boolean;
20 | };
21 |
22 | const _ObjectNodeProperty = ({ nodeId, propertyK, propertyV, hasChildNode }: Props) => {
23 | const hoveredNodeDetails = useNodeDetailViewStore((state) => state.hoveredNodeDetails);
24 | const { theme } = useCustomTheme();
25 |
26 | const { isObjectData, isArrayData, isStringData, isNumberData, isBooleanData, isNullData } =
27 | validateJsonDataType(propertyV);
28 |
29 | const isHoveredFromNodeDetail: boolean = hoveredNodeDetails.some(
30 | (item) => item.nodeId === nodeId && item.propertyK === propertyK,
31 | );
32 |
33 | const iconColor = semanticColors[theme].default[500];
34 |
35 | return (
36 |
37 | {propertyK}
38 |
39 | {isObjectData && (
40 |
41 | )}
42 | {isArrayData && (
43 |
44 | )}
45 |
46 | {isStringData && (
47 | {JSON.stringify(propertyV)}
48 | )}
49 | {isNumberData && (
50 | {propertyV}
51 | )}
52 | {isBooleanData && }
53 | {isNullData && }
54 |
55 | {hasChildNode && (
56 |
57 | )}
58 |
59 | {isHoveredFromNodeDetail && }
60 |
61 | );
62 | };
63 |
64 | export const ObjectNodeProperty = memo(_ObjectNodeProperty);
65 |
--------------------------------------------------------------------------------
/src/json-diagram/components/PrimitiveNode.tsx:
--------------------------------------------------------------------------------
1 | import { semanticColors } from '@nextui-org/theme';
2 | import { memo } from 'react';
3 | import { NodeProps } from 'reactflow';
4 | import { JsonDataType } from '../../store/json-engine/enums/json-data-type.enum';
5 | import { NodeType } from '../../store/json-engine/enums/node-type.enum';
6 | import { addPrefixChain } from '../../store/json-engine/helpers/json-parser.helper';
7 | import { PrimitiveNodeData } from '../../store/json-engine/types/sea-node.type';
8 | import { useNodeDetailViewStore } from '../../store/node-detail-view/node-detail-view.store';
9 | import { BooleanChip } from '../../ui/components/BooleanChip';
10 | import { NullChip } from '../../ui/components/NullChip';
11 | import { Text } from '../../ui/components/Text';
12 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
13 | import { useHighlighter } from '../hooks/useHighlighter';
14 | import { ChainHandle } from './ChainHandle';
15 | import { DefaultHandle } from './DefaultHandle';
16 | import { HoveringBlueDot } from './HoveringBlueDot';
17 | import { NodeShell } from './NodeShell';
18 |
19 | /**
20 | * PrimitiveNode `` Details
21 | *
22 | * source: impossible to have.
23 | * target: always have.
24 | */
25 | const _PrimitiveNode = ({ id, data }: NodeProps) => {
26 | const hoveredNodeDetails = useNodeDetailViewStore((state) => state.hoveredNodeDetails);
27 | const { isHighlightNode } = useHighlighter();
28 | const { theme } = useCustomTheme();
29 |
30 | const isHoveredFromNodeDetail: boolean = hoveredNodeDetails.some(({ nodeId }) => nodeId === id);
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
43 | {data.dataType === JsonDataType.String && data.stringifiedJson}
44 |
45 | {data.dataType === JsonDataType.Number && data.value}
46 |
47 | {data.dataType === JsonDataType.Boolean && }
48 |
49 | {data.dataType === JsonDataType.Null && }
50 |
51 |
52 | {isHoveredFromNodeDetail && }
53 |
54 |
55 | );
56 | };
57 |
58 | export const PrimitiveNode = memo(_PrimitiveNode);
59 |
--------------------------------------------------------------------------------
/src/json-diagram/constants/root-node.constant.ts:
--------------------------------------------------------------------------------
1 | export const CURLY_ROOT_NODE_NAME = '{root}';
2 |
3 | export const ROOT_NODE_NAME = 'root';
4 |
5 | export const ROOT_NODE_DEPTH = 0;
6 |
7 | export const ROOT_PARENT_NODE_PATH_IDS: string[] = [];
8 |
9 | export const ARRAY_ROOT_NODE_INDEX = -9999;
10 |
--------------------------------------------------------------------------------
/src/json-diagram/hooks/useHighlighter.ts:
--------------------------------------------------------------------------------
1 | import { Edge, useEdges } from 'reactflow';
2 | import { HoveredNodeDetail, useNodeDetailViewStore } from '../../store/node-detail-view/node-detail-view.store';
3 | import { isString } from '../../utils/json.util';
4 |
5 | const isConnectedEdgeToHovered = (edge: Edge, hoveredNodeDetails: HoveredNodeDetail[]): boolean => {
6 | const { source, sourceHandle, target } = edge;
7 |
8 | return hoveredNodeDetails.some(({ nodeId, propertyK }) => {
9 | if (isString(propertyK)) {
10 | return nodeId === source && propertyK === sourceHandle; // Hovered from `ObjectNodeDetail`
11 | } else {
12 | return nodeId === target; // Hovered from `ArrayNodeDetail` or `PrimitiveNodeDetail`
13 | }
14 | });
15 | };
16 |
17 | export const useHighlighter = () => {
18 | const hoveredNodeDetails = useNodeDetailViewStore((state) => state.hoveredNodeDetails);
19 | const edges = useEdges();
20 |
21 | const highlightedEdges: Edge[] = edges.filter((edge) => isConnectedEdgeToHovered(edge, hoveredNodeDetails));
22 |
23 | const isHighlightEdge = (edgeId: string) => highlightedEdges.some(({ id }) => id === edgeId);
24 |
25 | /**
26 | * If the left edge is highlighted, the node connected to left edge should also be highlighted.
27 | * Therefore, check `target` value.
28 | */
29 | const isHighlightNode = (nodeId: string) => highlightedEdges.some(({ target }) => target === nodeId);
30 |
31 | return { isHighlightEdge, isHighlightNode } as const;
32 | };
33 |
--------------------------------------------------------------------------------
/src/json-diagram/styles/handle.style.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react';
2 |
3 | export const hiddenHandleStyle: CSSProperties = {
4 | backgroundColor: 'transparent',
5 | border: 'none',
6 | };
7 |
--------------------------------------------------------------------------------
/src/json-editor/components/DragDropJsonFile.tsx:
--------------------------------------------------------------------------------
1 | import { semanticColors } from '@nextui-org/react';
2 | import { memo, useId } from 'react';
3 | import { Text } from '../../ui/components/Text';
4 | import { Icon } from '../../ui/icon/Icon';
5 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
6 | import { useDragDropJsonFile } from '../hooks/useDragDropJsonFile';
7 |
8 | type Props = {
9 | afterFileReadSuccess: () => void;
10 | };
11 |
12 | const dropzoneClassName = {
13 | normal: 'json-dropzone-base bg-default-50',
14 | dragging: 'json-dropzone-base bg-success-200',
15 | };
16 |
17 | const _DragDropJsonFile = ({ afterFileReadSuccess }: Props) => {
18 | const fileInputId: string = useId();
19 | const { theme } = useCustomTheme();
20 | const { dropzoneRef, isDragging, handleFileInputChange } = useDragDropJsonFile(afterFileReadSuccess);
21 |
22 | return (
23 |
24 |
31 |
32 |
46 |
47 | );
48 | };
49 |
50 | export const DragDropJsonFile = memo(_DragDropJsonFile);
51 |
--------------------------------------------------------------------------------
/src/json-editor/components/ImportJsonModal.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button } from '@nextui-org/button';
4 | import { Input } from '@nextui-org/input';
5 | import { Modal, ModalBody, ModalContent, ModalHeader } from '@nextui-org/modal';
6 | import { ComponentProps, memo, useCallback, useEffect } from 'react';
7 | import { useJsonDiagramViewStore } from '../../store/json-diagram-view/json-diagram-view.store';
8 | import { useJsonEngineStore } from '../../store/json-engine/json-engine.store';
9 | import { Text } from '../../ui/components/Text';
10 | import { formatJsonLikeData, isArray, isNull, isObject, isValidJson } from '../../utils/json.util';
11 | import { useSimpleFetch } from '../../utils/react-hooks/useSimpleFetch';
12 | import { useString } from '../../utils/react-hooks/useString';
13 | import { DragDropJsonFile } from './DragDropJsonFile';
14 |
15 | type Props = {
16 | isModalOpen: boolean;
17 | closeModal: () => void;
18 | };
19 |
20 | const _ImportJsonModal = ({ isModalOpen, closeModal }: Props) => {
21 | const {
22 | string: jsonUrlValue,
23 | isEmpty: isJsonUrlValueEmpty,
24 | setString: setJsonUrlValue,
25 | clearString: clearJsonUrlValue,
26 | } = useString();
27 | const {
28 | loading: isGetJsonLoading,
29 | data: getJsonResponse,
30 | error: getJsonError,
31 | fetchUrl: fetchJsonUrl,
32 | resetError: resetGetJsonError,
33 | } = useSimpleFetch();
34 |
35 | const setStringifiedJson = useJsonEngineStore((state) => state.setStringifiedJson);
36 | const resetSelectedNode = useJsonDiagramViewStore((state) => state.resetSelectedNode);
37 |
38 | const handleJsonUrlValueChange: ComponentProps['onChange'] = useCallback(
39 | (e: React.ChangeEvent) => {
40 | setJsonUrlValue(e.target.value);
41 | resetGetJsonError();
42 | },
43 | [setJsonUrlValue, resetGetJsonError],
44 | );
45 |
46 | const handleJsonUrlValueClear: ComponentProps['onClear'] = useCallback(() => {
47 | clearJsonUrlValue();
48 | resetGetJsonError();
49 | }, [clearJsonUrlValue, resetGetJsonError]);
50 |
51 | const handleJsonUrlInputKeyDown: ComponentProps['onKeyDown'] = (e) => {
52 | if (e.key === 'Enter' && !isJsonUrlValueEmpty) {
53 | fetchJsonUrl(jsonUrlValue);
54 | }
55 | };
56 |
57 | useEffect(() => {
58 | if (!isModalOpen) {
59 | resetGetJsonError();
60 | clearJsonUrlValue();
61 | }
62 | }, [isModalOpen, resetGetJsonError, clearJsonUrlValue]);
63 |
64 | useEffect(() => {
65 | if (isObject(getJsonResponse) || isArray(getJsonResponse)) {
66 | const formattedData: string = formatJsonLikeData(getJsonResponse);
67 |
68 | if (isValidJson(formattedData)) {
69 | setStringifiedJson(formattedData);
70 | resetSelectedNode();
71 | closeModal();
72 | }
73 | }
74 | }, [getJsonResponse, setStringifiedJson, resetSelectedNode, closeModal]);
75 |
76 | const isInvalid = !isNull(getJsonError);
77 |
78 | return (
79 |
80 |
81 | {(onClose) => (
82 | <>
83 | Import JSON via URL or File
84 |
85 |
86 |
104 |
113 |
114 |
115 | or
116 |
117 |
118 |
119 | >
120 | )}
121 |
122 |
123 | );
124 | };
125 |
126 | export const ImportJsonModal = memo(_ImportJsonModal);
127 |
--------------------------------------------------------------------------------
/src/json-editor/components/JsonEditor.tsx:
--------------------------------------------------------------------------------
1 | import Editor from '@monaco-editor/react';
2 | import { useCallback } from 'react';
3 | import { useJsonDiagramViewStore } from '../../store/json-diagram-view/json-diagram-view.store';
4 | import { DEFAULT_STRINGIFIED_JSON } from '../../store/json-engine/json-engine.constant';
5 | import { useJsonEngineStore } from '../../store/json-engine/json-engine.store';
6 | import { isValidJson } from '../../utils/json.util';
7 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
8 | import { JsonEditorConsole } from './JsonEditorConsole';
9 | import { JsonValidityStatus } from './JsonValidityStatus';
10 |
11 | // TODO: useDefferedValue hook to optimize?
12 | const _JsonEditor = () => {
13 | const [stringifiedJson, setStringifiedJson] = useJsonEngineStore((state) => [
14 | state.stringifiedJson,
15 | state.setStringifiedJson,
16 | ]);
17 | const resetSelectedNode = useJsonDiagramViewStore((state) => state.resetSelectedNode);
18 |
19 | const { isDarkMode } = useCustomTheme();
20 |
21 | const handleEditorChange = useCallback(
22 | (value: string | undefined) => {
23 | if (value === undefined) return;
24 |
25 | setStringifiedJson(value);
26 |
27 | if (isValidJson(value)) {
28 | resetSelectedNode();
29 | }
30 | },
31 | [setStringifiedJson, resetSelectedNode],
32 | );
33 |
34 | return (
35 |
36 |
52 |
53 |
60 |
61 |
69 |
70 | );
71 | };
72 |
73 | export const JsonEditor = _JsonEditor;
74 |
--------------------------------------------------------------------------------
/src/json-editor/components/JsonEditorConsole.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@nextui-org/button';
2 | import { useDisclosure } from '@nextui-org/modal';
3 | import { semanticColors } from '@nextui-org/react';
4 | import { Tooltip } from '@nextui-org/tooltip';
5 | import { ComponentProps, memo, useMemo } from 'react';
6 | import { useJsonEngineStore } from '../../store/json-engine/json-engine.store';
7 | import { Icon } from '../../ui/icon/Icon';
8 | import { downloadAsFile } from '../../utils/file-download.util';
9 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
10 | import { ImportJsonModal } from './ImportJsonModal';
11 |
12 | type Props = {
13 | style?: React.CSSProperties;
14 | };
15 |
16 | const _JsonEditorConsole = ({ style }: Props) => {
17 | const [stringifiedJson, isValidJson] = useJsonEngineStore((state) => [state.stringifiedJson, state.isValidJson]);
18 |
19 | const { isOpen: isImportJsonModalOpen, onOpen: openImportJsonModal, onClose: closeImportJsonModal } = useDisclosure();
20 | const { theme } = useCustomTheme();
21 |
22 | const handleDownloadJsonClick = () => {
23 | downloadAsFile(`data:text/json;charset=utf8,${encodeURIComponent(stringifiedJson)}`, 'json-sea.json');
24 | };
25 |
26 | const sharedTooltipProps: ComponentProps = useMemo(
27 | () => ({
28 | className: 'px-2',
29 | delay: 0,
30 | closeDelay: 0,
31 | color: 'primary',
32 | }),
33 | [],
34 | );
35 |
36 | const sharedButtonProps: ComponentProps = useMemo(
37 | () => ({
38 | className: 'w-full',
39 | isIconOnly: true,
40 | variant: 'light',
41 | color: 'primary',
42 | }),
43 | [],
44 | );
45 |
46 | const iconColor = useMemo(() => (semanticColors[theme].primary as any).DEFAULT, [theme]);
47 |
48 | return (
49 | <>
50 |
51 |
52 |
56 |
57 |
60 |
61 |
62 |
63 |
66 |
67 |
68 | >
69 | );
70 | };
71 |
72 | export const JsonEditorConsole = memo(_JsonEditorConsole);
73 |
--------------------------------------------------------------------------------
/src/json-editor/components/JsonValidityStatus.tsx:
--------------------------------------------------------------------------------
1 | import { semanticColors } from '@nextui-org/react';
2 | import { memo } from 'react';
3 | import { useJsonEngineStore } from '../../store/json-engine/json-engine.store';
4 | import { Icon } from '../../ui/icon/Icon';
5 | import { useCustomTheme } from '../../utils/react-hooks/useCustomTheme';
6 |
7 | type Props = {
8 | style?: React.CSSProperties;
9 | };
10 |
11 | const _JsonValidityStatus = ({ style }: Props) => {
12 | const isValidJson = useJsonEngineStore((state) => state.isValidJson);
13 | const { theme } = useCustomTheme();
14 |
15 | return (
16 |
17 |
24 |
25 | );
26 | };
27 |
28 | export const JsonValidityStatus = memo(_JsonValidityStatus);
29 |
--------------------------------------------------------------------------------
/src/json-editor/components/ResizableJsonEditor.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Resizable } from 're-resizable';
4 | import { useJsonEditorViewStore } from '../../store/json-editor-view/json-editor-view.store';
5 | import { JsonEditor } from './JsonEditor';
6 |
7 | const _ResizableJsonEditor = () => {
8 | const isJsonEditorVisible = useJsonEditorViewStore((state) => state.isJsonEditorVisible);
9 |
10 | return (
11 |
26 |
27 |
28 | );
29 | };
30 |
31 | export const ResizableJsonEditor = _ResizableJsonEditor;
32 |
--------------------------------------------------------------------------------
/src/json-editor/hooks/useDragDropJsonFile.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from 'react';
2 | import { useJsonDiagramViewStore } from '../../store/json-diagram-view/json-diagram-view.store';
3 | import { useJsonEngineStore } from '../../store/json-engine/json-engine.store';
4 | import { formatJsonLikeData, isValidJson } from '../../utils/json.util';
5 | import { useBoolean } from '../../utils/react-hooks/useBoolean';
6 |
7 | export const useDragDropJsonFile = (afterFileReadSuccess: () => void) => {
8 | const setStringifiedJson = useJsonEngineStore((state) => state.setStringifiedJson);
9 | const resetSelectedNode = useJsonDiagramViewStore((state) => state.resetSelectedNode);
10 |
11 | const { bool: isDragging, setTrue: onIsDragging, setFalse: offIsDragging } = useBoolean();
12 | const dropzoneRef = useRef(null);
13 |
14 | const processJsonFileText = useCallback(
15 | (jsonFileText: string): void => {
16 | if (isValidJson(jsonFileText)) {
17 | const formattedJson: string = formatJsonLikeData(jsonFileText);
18 |
19 | setStringifiedJson(formattedJson);
20 | resetSelectedNode();
21 | afterFileReadSuccess();
22 | }
23 | },
24 | [setStringifiedJson, resetSelectedNode, afterFileReadSuccess]
25 | );
26 |
27 | const handleDragIn = useCallback(
28 | (e: DragEvent): void => {
29 | e.preventDefault();
30 | e.stopPropagation();
31 |
32 | onIsDragging();
33 | },
34 | [onIsDragging]
35 | );
36 |
37 | const handleDragOut = useCallback(
38 | (e: DragEvent): void => {
39 | e.preventDefault();
40 | e.stopPropagation();
41 |
42 | offIsDragging();
43 | },
44 | [offIsDragging]
45 | );
46 |
47 | const handleDragOver = useCallback(
48 | (e: DragEvent): void => {
49 | e.preventDefault();
50 | e.stopPropagation();
51 |
52 | onIsDragging();
53 | },
54 | [onIsDragging]
55 | );
56 |
57 | const handleDrop = useCallback(
58 | (e: DragEvent): void => {
59 | e.preventDefault();
60 | e.stopPropagation();
61 |
62 | offIsDragging();
63 |
64 | if (e.dataTransfer?.files && e.dataTransfer?.files[0]) {
65 | const file: File = e.dataTransfer.files[0];
66 |
67 | if (file.type === 'application/json') {
68 | file.text().then((jsonText: string) => {
69 | processJsonFileText(jsonText);
70 | });
71 | }
72 | }
73 | },
74 | [offIsDragging, processJsonFileText]
75 | );
76 |
77 | const handleFileInputChange: React.ChangeEventHandler = useCallback(
78 | (e) => {
79 | e.preventDefault();
80 |
81 | if (e.target.files && e.target.files[0]) {
82 | const jsonFile: File = e.target.files[0]; // tag has accept="application/JSON" attribute.
83 | jsonFile.text().then((jsonText: string) => {
84 | processJsonFileText(jsonText);
85 | });
86 | }
87 | },
88 | [processJsonFileText]
89 | );
90 |
91 | useEffect(() => {
92 | const dropzoneElement: HTMLLabelElement | null = dropzoneRef?.current;
93 |
94 | dropzoneElement?.addEventListener('dragenter', handleDragIn);
95 | dropzoneElement?.addEventListener('dragleave', handleDragOut);
96 | dropzoneElement?.addEventListener('dragover', handleDragOver);
97 | dropzoneElement?.addEventListener('drop', handleDrop);
98 |
99 | return () => {
100 | dropzoneElement?.removeEventListener('dragenter', handleDragIn);
101 | dropzoneElement?.removeEventListener('dragleave', handleDragOut);
102 | dropzoneElement?.removeEventListener('dragover', handleDragOver);
103 | dropzoneElement?.removeEventListener('drop', handleDrop);
104 | };
105 | }, [dropzoneRef, handleDragIn, handleDragOut, handleDragOver, handleDrop]);
106 |
107 | return {
108 | dropzoneRef,
109 | isDragging,
110 | handleFileInputChange,
111 | };
112 | };
113 |
--------------------------------------------------------------------------------
/src/node-detail/array/components/ArrayInspector.tsx:
--------------------------------------------------------------------------------
1 | import Editor from '@monaco-editor/react';
2 | import { Card } from '@nextui-org/card';
3 | import { memo } from 'react';
4 | import { formatJsonLikeData } from '../../../utils/json.util';
5 | import { useCustomTheme } from '../../../utils/react-hooks/useCustomTheme';
6 |
7 | type Props = {
8 | array: any[];
9 | };
10 |
11 | const _ArrayInspector = ({ array }: Props) => {
12 | const { isDarkMode } = useCustomTheme();
13 |
14 | return (
15 |
16 |
31 |
32 | );
33 | };
34 |
35 | export const ArrayInspector = memo(_ArrayInspector);
36 |
--------------------------------------------------------------------------------
/src/node-detail/array/components/ArrayItemCard.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useMemo } from 'react';
2 | import { useHoverNodeDetails } from '../../../store/node-detail-view/hooks/useHoverNodeDetails';
3 | import { isObject } from '../../../utils/json.util';
4 | import { NodeDetailCard } from '../../components/NodeDetailCard';
5 | import { useNodePath } from '../../hooks/useNodePath';
6 | import { ArrayItemNameChip } from './ArrayItemNameChip';
7 |
8 | type Props = {
9 | selfNodeId: string;
10 | value: any;
11 | };
12 |
13 | const _ArrayItemCard = ({ selfNodeId, value }: Props) => {
14 | const { cardRef } = useHoverNodeDetails([{ nodeId: selfNodeId }]);
15 | const { selfNodePath } = useNodePath(selfNodeId);
16 |
17 | const objectNodeId: string | null = useMemo(() => (isObject(value) ? selfNodeId : null), [value, selfNodeId]);
18 |
19 | return (
20 | }
23 | value={value}
24 | childObjectNodeId={objectNodeId}
25 | />
26 | );
27 | };
28 |
29 | export const ArrayItemCard = memo(_ArrayItemCard);
30 |
--------------------------------------------------------------------------------
/src/node-detail/array/components/ArrayItemNameChip.tsx:
--------------------------------------------------------------------------------
1 | import { Chip } from '@nextui-org/chip';
2 | import { memo } from 'react';
3 |
4 | type Props = {
5 | arrayItemName: string;
6 | };
7 |
8 | const _ArrayItemNameChip = ({ arrayItemName }: Props) => {
9 | return (
10 |
11 | {arrayItemName}
12 |
13 | );
14 | };
15 |
16 | export const ArrayItemNameChip = memo(_ArrayItemNameChip);
17 |
--------------------------------------------------------------------------------
/src/node-detail/array/components/ArrayItemPrimitiveCard.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { useHoverNodeDetails } from '../../../store/node-detail-view/hooks/useHoverNodeDetails';
3 | import { NodeDetailCard } from '../../components/NodeDetailCard';
4 |
5 | type Props = {
6 | nodeId: string;
7 | arrayItemIndex: number;
8 | value: string | number | boolean | null;
9 | };
10 |
11 | /**
12 | * Parent node is always a ArrayNode.
13 | */
14 | const _ArrayItemPrimitiveCard = ({ nodeId, arrayItemIndex, value }: Props) => {
15 | const { cardRef } = useHoverNodeDetails([{ nodeId }]);
16 |
17 | return ;
18 | };
19 |
20 | export const ArrayItemPrimitiveCard = memo(_ArrayItemPrimitiveCard);
21 |
--------------------------------------------------------------------------------
/src/node-detail/array/components/ArrayNodeDetail.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { Edge } from 'reactflow';
3 | import { NodeType } from '../../../store/json-engine/enums/node-type.enum';
4 | import { useJsonEngineStore } from '../../../store/json-engine/json-engine.store';
5 | import { ArrayNodeData } from '../../../store/json-engine/types/sea-node.type';
6 | import { isEmptyArray } from '../../../utils/array.util';
7 | import { EmptyNodeMessage } from '../../components/EmptyNodeMessage';
8 | import { NodeDetailList } from '../../components/NodeDetailList';
9 | import { ArrayItemCard } from './ArrayItemCard';
10 |
11 | type Props = {
12 | nodeId: string;
13 | nodeData: ArrayNodeData;
14 | };
15 |
16 | const getArrayItemNodeId = (edges: Edge[], parentNodeId: string, index: number): string => {
17 | const connectedNodeIds: string[] = edges.filter(({ source }) => source === parentNodeId).map(({ target }) => target);
18 | const uniqConnectedNodeIds: string[] = Array.from(new Set(connectedNodeIds));
19 |
20 | return uniqConnectedNodeIds[index];
21 | };
22 |
23 | const _ArrayNodeDetail = ({ nodeId, nodeData }: Props) => {
24 | const { edges } = useJsonEngineStore((state) => state.jsonTree);
25 | const { items } = nodeData;
26 |
27 | const parentNodeId: string = nodeId; // On each `ArrayItemCard` perspective, their parentNodeId is nodeId.
28 |
29 | return (
30 |
31 | {isEmptyArray(items) ? (
32 |
33 | ) : (
34 | items.map((value: any, index: number) => (
35 |
36 | ))
37 | )}
38 |
39 | );
40 | };
41 |
42 | export const ArrayNodeDetail = memo(_ArrayNodeDetail);
43 |
--------------------------------------------------------------------------------
/src/node-detail/array/helpers/node-type.helper.ts:
--------------------------------------------------------------------------------
1 | import { NodeType } from '../../../store/json-engine/enums/node-type.enum';
2 |
3 | export const nodeTypeToTextMap: Record = {
4 | [NodeType.Object]: 'Object',
5 | [NodeType.Array]: 'Array',
6 | [NodeType.Primitive]: 'Primitive',
7 | };
8 |
9 | export const nodeTypeToAcronymMap: Record = {
10 | [NodeType.Object]: 'O',
11 | [NodeType.Array]: 'A',
12 | [NodeType.Primitive]: 'P',
13 | };
14 |
--------------------------------------------------------------------------------
/src/node-detail/components/DataTypeText.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { Text } from '../../ui/components/Text';
3 |
4 | type Props = {
5 | value: string;
6 | };
7 |
8 | const _DataTypeText = ({ value }: Props) => {
9 | return {value};
10 | };
11 |
12 | export const DataTypeText = memo(_DataTypeText);
13 |
--------------------------------------------------------------------------------
/src/node-detail/components/DetailArray.tsx:
--------------------------------------------------------------------------------
1 | import { CardBody, CardHeader } from '@nextui-org/card';
2 | import { ReactElement, isValidElement, memo, useMemo } from 'react';
3 | import { JsonDataType } from '../../store/json-engine/enums/json-data-type.enum';
4 | import { getJsonDataType } from '../../store/json-engine/helpers/json-data-type.helper';
5 | import { formatCounting } from '../../utils/string.util';
6 | import { ArrayInspector } from '../array/components/ArrayInspector';
7 | import { DataTypeText } from './DataTypeText';
8 |
9 | type Props = {
10 | chip?: ReactElement;
11 | array: any[];
12 | };
13 |
14 | const _DetailArray = ({ chip, array }: Props) => {
15 | const dataTypeText: string = useMemo(() => {
16 | const jsonDataType: JsonDataType = getJsonDataType(array);
17 | const counting: string = formatCounting(array.length, 'item', 'items');
18 |
19 | return `${jsonDataType} ${counting}`;
20 | }, [array]);
21 |
22 | return (
23 | <>
24 |
25 |
26 | {isValidElement(chip) && chip}
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | >
35 | );
36 | };
37 |
38 | export const DetailArray = memo(_DetailArray);
39 |
--------------------------------------------------------------------------------
/src/node-detail/components/DetailObject.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@nextui-org/button';
2 | import { CardBody, CardHeader } from '@nextui-org/card';
3 | import { ReactElement, isValidElement, memo, useMemo } from 'react';
4 | import { useJsonDiagramViewStore } from '../../store/json-diagram-view/json-diagram-view.store';
5 | import { JsonDataType } from '../../store/json-engine/enums/json-data-type.enum';
6 | import { getJsonDataType } from '../../store/json-engine/helpers/json-data-type.helper';
7 | import { formatCounting } from '../../utils/string.util';
8 | import { DataTypeText } from './DataTypeText';
9 |
10 | type Props = {
11 | chip?: ReactElement;
12 | obj: object;
13 | childObjectNodeId: string;
14 | };
15 |
16 | const _DetailObject = ({ chip, obj, childObjectNodeId }: Props) => {
17 | const selectNode = useJsonDiagramViewStore((state) => state.selectNode);
18 |
19 | const dataTypeText: string = useMemo(() => {
20 | const jsonDataType: JsonDataType = getJsonDataType(obj);
21 | const counting: string = formatCounting(Object.keys(obj).length, 'property', 'properties');
22 |
23 | return `${jsonDataType} ${counting}`;
24 | }, [obj]);
25 |
26 | return (
27 | <>
28 |
29 |
30 | {isValidElement(chip) && chip}
31 |
32 |
33 |
34 |
35 |
36 |
39 |
40 | >
41 | );
42 | };
43 |
44 | export const DetailObject = memo(_DetailObject);
45 |
--------------------------------------------------------------------------------
/src/node-detail/components/DetailPrimitive.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { CardBody, CardHeader } from '@nextui-org/card';
4 | import { ReactElement, isValidElement, memo, useEffect, useMemo, useState } from 'react';
5 | import { JsonDataType } from '../../store/json-engine/enums/json-data-type.enum';
6 | import { getJsonDataType } from '../../store/json-engine/helpers/json-data-type.helper';
7 | import { BooleanChip } from '../../ui/components/BooleanChip';
8 | import { NullChip } from '../../ui/components/NullChip';
9 | import { isBoolean, isNull, isNumber, isString } from '../../utils/json.util';
10 | import { NumberInspector } from '../primitive/components/NumberInspector';
11 | import { StringInspector } from '../primitive/components/StringInspector';
12 | import { ALL_FALSE_STRING_SUBTYPE_VALIDATOR } from '../primitive/constants/string-subtype.constant';
13 | import { StringSubtypeValidator, validateStringSubtype } from '../primitive/helpers/string-subtype.helper';
14 | import { DataTypeText } from './DataTypeText';
15 |
16 | type Props = {
17 | chip?: ReactElement;
18 | value: string | number | boolean | null;
19 | };
20 |
21 | const getStringSubtypeText = (stringSubtypeValidator: StringSubtypeValidator): string => {
22 | if (stringSubtypeValidator.isColor) return '/color';
23 | if (stringSubtypeValidator.isDatetime) return '/datetime';
24 | if (stringSubtypeValidator.isEmail) return '/email';
25 | if (stringSubtypeValidator.isHttpUri) return '/uri';
26 | if (stringSubtypeValidator.isImage) return '/image';
27 | if (stringSubtypeValidator.isImageUri) return '/imageUri';
28 | if (stringSubtypeValidator.isAudio) return '/audio';
29 | if (stringSubtypeValidator.isAudioUri) return '/audioUri';
30 | if (stringSubtypeValidator.isVideo) return '/video';
31 | if (stringSubtypeValidator.isVideoUri) return '/videoUri';
32 |
33 | return '';
34 | };
35 |
36 | const _DetailPrimitive = ({ chip, value }: Props) => {
37 | const [isStringSubtypeLoading, setIsStringSubtypeLoading] = useState(isString(value));
38 | const [stringSubtypeValidator, setStringSubtypeValidator] = useState(
39 | ALL_FALSE_STRING_SUBTYPE_VALIDATOR,
40 | );
41 |
42 | useEffect(() => {
43 | if (isString(value)) {
44 | validateStringSubtype(value).then((result: StringSubtypeValidator) => {
45 | setStringSubtypeValidator(result);
46 | setIsStringSubtypeLoading(false);
47 | });
48 | }
49 | }, [value]);
50 |
51 | const dataTypeText: string = useMemo(() => {
52 | const jsonDataType: JsonDataType = getJsonDataType(value);
53 |
54 | return jsonDataType === JsonDataType.String
55 | ? jsonDataType.concat(getStringSubtypeText(stringSubtypeValidator))
56 | : jsonDataType;
57 | }, [value, stringSubtypeValidator]);
58 |
59 | return (
60 | <>
61 |
62 |
63 | {isValidElement(chip) && chip}
64 |
65 |
66 |
67 |
68 |
69 | {isString(value) && (
70 |
75 | )}
76 |
77 | {isNumber(value) && }
78 |
79 | {isBoolean(value) && }
80 |
81 | {isNull(value) && }
82 |
83 | >
84 | );
85 | };
86 |
87 | export const DetailPrimitive = memo(_DetailPrimitive);
88 |
--------------------------------------------------------------------------------
/src/node-detail/components/EmptyNodeMessage.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import { memo } from 'react';
3 | import { NodeType } from '../../store/json-engine/enums/node-type.enum';
4 | import { Text } from '../../ui/components/Text';
5 | import { nodeTypeToTextMap } from '../array/helpers/node-type.helper';
6 |
7 | type Props = {
8 | nodeType: NodeType.Object | NodeType.Array;
9 | };
10 |
11 | const nodeTypeToSyntaxMap: Record = {
12 | [NodeType.Object]: '{ }',
13 | [NodeType.Array]: '[ ]',
14 | };
15 |
16 | const _EmptyNodeMessage = ({ nodeType }: Props) => {
17 | return (
18 |
19 |
20 | {nodeTypeToSyntaxMap[nodeType]}
21 |
22 | You have just selected an empty {nodeTypeToTextMap[nodeType].toLowerCase()}.
23 |
24 |
25 | );
26 | };
27 |
28 | export const EmptyNodeMessage = memo(_EmptyNodeMessage);
29 |
--------------------------------------------------------------------------------
/src/node-detail/components/NodeDetailCard.tsx:
--------------------------------------------------------------------------------
1 | import { Card } from '@nextui-org/card';
2 | import { ForwardedRef, forwardRef, memo, ReactElement } from 'react';
3 | import { isArray, isString } from '../../utils/json.util';
4 | import { DetailArray } from './DetailArray';
5 | import { DetailObject } from './DetailObject';
6 | import { DetailPrimitive } from './DetailPrimitive';
7 |
8 | type Props = {
9 | chip?: ReactElement;
10 | value: object | any[] | string | number | boolean | null;
11 | childObjectNodeId: string | null;
12 | };
13 |
14 | const _NodeDetailCard = ({ chip, value, childObjectNodeId }: Props, ref: ForwardedRef) => {
15 | return (
16 |
17 |
18 |
19 | {isString(childObjectNodeId) ? (
20 |
21 | ) : isArray(value) ? (
22 |
23 | ) : (
24 |
25 | )}
26 |
27 | );
28 | };
29 |
30 | export const NodeDetailCard = memo(forwardRef(_NodeDetailCard));
31 |
--------------------------------------------------------------------------------
/src/node-detail/components/NodeDetailChip.tsx:
--------------------------------------------------------------------------------
1 | import { Chip } from '@nextui-org/chip';
2 | import { memo } from 'react';
3 |
4 | type Props = {
5 | value: string;
6 | };
7 |
8 | const _NodeDetailChip = ({ value }: Props) => {
9 | return (
10 |
11 | {value}
12 |
13 | );
14 | };
15 |
16 | export const NodeDetailChip = memo(_NodeDetailChip);
17 |
--------------------------------------------------------------------------------
/src/node-detail/components/NodeDetailList.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 |
3 | type Props = {
4 | children: React.ReactNode;
5 | };
6 |
7 | const _NodeDetailList = ({ children }: Props) => {
8 | return {children}
;
9 | };
10 |
11 | export const NodeDetailList = memo(_NodeDetailList);
12 |
--------------------------------------------------------------------------------
/src/node-detail/components/NodeDetailPanel.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { memo, useEffect, useRef } from 'react';
4 | import { Copyright } from '../../foundation/components/Copyright';
5 | import { useJsonDiagramViewStore } from '../../store/json-diagram-view/json-diagram-view.store';
6 | import { isArraySeaNode, isObjectSeaNode, isPrimitiveSeaNode } from '../../store/json-engine/helpers/sea-node.helper';
7 | import { useJsonEngineStore } from '../../store/json-engine/json-engine.store';
8 | import { SeaNode } from '../../store/json-engine/types/sea-node.type';
9 | import { Text } from '../../ui/components/Text';
10 | import { isNull } from '../../utils/json.util';
11 | import { useEnv } from '../../utils/react-hooks/useEnv';
12 | import { encloseDoubleQuote } from '../../utils/string.util';
13 | import { ArrayNodeDetail } from '../array/components/ArrayNodeDetail';
14 | import { ObjectNodeDetail } from '../object/components/ObjectNodeDetail';
15 | import { PrimitiveNodeDetail } from '../primitive/components/PrimitiveNodeDetail';
16 | import { NodeDetailPanelHeader } from './NodeDetailPanelHeader';
17 |
18 | const useSelectedNode = () => {
19 | const selectedNodeId = useJsonDiagramViewStore((state) => state.selectedNodeId);
20 | const { seaNodeEntities } = useJsonEngineStore((state) => state.jsonTree);
21 |
22 | return isNull(selectedNodeId) ? null : seaNodeEntities[selectedNodeId] ?? null;
23 | };
24 |
25 | const _NodeDetailPanel = () => {
26 | const selectedNode: SeaNode | null = useSelectedNode();
27 | const hostRef = useRef(null);
28 |
29 | const { isLocalhost } = useEnv();
30 |
31 | useEffect(() => {
32 | if (!!hostRef?.current) {
33 | hostRef.current.scrollTo({ top: 0 });
34 | }
35 | }, [selectedNode, hostRef]);
36 |
37 | return (
38 |
42 | {isNull(selectedNode) ? (
43 |
No selected node.
44 | ) : (
45 | <>
46 |
47 |
48 | {isLocalhost && (
49 | <>
50 |
51 | nodeId is {encloseDoubleQuote(selectedNode.id)}
52 |
53 |
54 | parentNodePath is [{selectedNode.data.parentNodePathIds.join(' > ')}]
55 |
56 | >
57 | )}
58 |
59 | <>
60 | {isObjectSeaNode(selectedNode) && (
61 |
62 | )}
63 | {isArraySeaNode(selectedNode) &&
}
64 | {isPrimitiveSeaNode(selectedNode) && (
65 |
66 | )}
67 | >
68 | >
69 | )}
70 |
71 |
72 |
73 | );
74 | };
75 |
76 | export const NodeDetailPanel = memo(_NodeDetailPanel);
77 |
--------------------------------------------------------------------------------
/src/node-detail/components/NodeDetailPanelHeader.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardBody, CardHeader } from '@nextui-org/card';
2 | import { Chip } from '@nextui-org/chip';
3 | import { Spacer } from '@nextui-org/react';
4 | import { memo } from 'react';
5 | import { SeaNode } from '../../store/json-engine/types/sea-node.type';
6 | import { useSettingsStore } from '../../store/settings/settings.store';
7 | import { Text } from '../../ui/components/Text';
8 | import { nodeTypeToTextMap } from '../array/helpers/node-type.helper';
9 | import { useNodePath } from '../hooks/useNodePath';
10 | import { TextCopyBox } from '../primitive/components/TextCopyBox';
11 | import { NodeDetailChip } from './NodeDetailChip';
12 |
13 | type Props = {
14 | selectedNode: SeaNode;
15 | };
16 |
17 | const _NodeDetailPanelHeader = ({ selectedNode }: Props) => {
18 | const isNodePathOn = useSettingsStore((state) => state.isNodePathOn);
19 | const { fullNodePath, selfNodePath } = useNodePath(selectedNode.id);
20 |
21 | return (
22 |
23 |
24 | {selectedNode.type !== undefined && nodeTypeToTextMap[selectedNode.type]}
25 |
26 |
27 |
28 |
29 |
30 | {isNodePathOn && (
31 | <>
32 |
33 |
34 |
35 | Node Path
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | >
46 | )}
47 |
48 | );
49 | };
50 |
51 | export const NodeDetailPanelHeader = memo(_NodeDetailPanelHeader);
52 |
--------------------------------------------------------------------------------
/src/node-detail/hooks/useNodePath.ts:
--------------------------------------------------------------------------------
1 | import { Edge } from 'reactflow';
2 | import { CURLY_ROOT_NODE_NAME, ROOT_NODE_NAME } from '../../json-diagram/constants/root-node.constant';
3 | import { isArraySeaNode, isObjectSeaNode, isPrimitiveSeaNode } from '../../store/json-engine/helpers/sea-node.helper';
4 | import { JsonTree, useJsonEngineStore } from '../../store/json-engine/json-engine.store';
5 | import { SeaNode } from '../../store/json-engine/types/sea-node.type';
6 | import { isNumber } from '../../utils/json.util';
7 | import { encloseSquareBrackets } from '../../utils/string.util';
8 |
9 | type NodePath = {
10 | fullNodePath: string; // e.g. `{root}`, `{root}.field_1`, `{root}.something[0].field_2`, `{root}.array[3][2].field_3[4]`, `{root}[1][2]`, ...
11 | selfNodePath: string; // e.g. `{root}`, `field_1`, `field_2`, `field_3[4]`, `{root}[1][2]`, ...
12 | };
13 |
14 | const ROOT_NODE_PATH: NodePath = {
15 | fullNodePath: CURLY_ROOT_NODE_NAME,
16 | selfNodePath: ROOT_NODE_NAME,
17 | };
18 |
19 | const getObjectPropertyK = ({
20 | edges,
21 | source,
22 | target,
23 | }: {
24 | edges: Edge[];
25 | source: string;
26 | target: string;
27 | }): string | null | undefined => edges.find((edge) => edge.source === source && edge.target === target)?.sourceHandle;
28 |
29 | const getNodePath = (jsonTree: JsonTree, selfNodeId: string): NodePath => {
30 | const { seaNodeEntities, edges } = jsonTree;
31 | const selfNode = seaNodeEntities[selfNodeId];
32 |
33 | /**
34 | * [Unknown Issue]
35 | * If empty [] or {} json code is entered, 'n1' selfNodeId is passed for some reason.
36 | * (Only root node is rendered)
37 | */
38 | if (selfNode === undefined) {
39 | return ROOT_NODE_PATH;
40 | }
41 |
42 | const isRootSelfNode = (isObjectSeaNode(selfNode) || isArraySeaNode(selfNode)) && selfNode.data.isRootNode;
43 |
44 | if (isRootSelfNode) {
45 | return ROOT_NODE_PATH;
46 | }
47 |
48 | const fullNodePathIds = selfNode.data.parentNodePathIds.concat([selfNodeId]);
49 |
50 | const fullNodePath = fullNodePathIds
51 | .reverse()
52 | .reduce((acc: string, nodeId: string, index: number, _fullNodePathIds: string[]) => {
53 | const node: SeaNode = seaNodeEntities[nodeId];
54 |
55 | const parentNodeId = index + 1 <= _fullNodePathIds.length ? _fullNodePathIds[index + 1] : undefined;
56 | const parentNode: SeaNode | undefined = parentNodeId !== undefined ? seaNodeEntities[parentNodeId] : undefined;
57 |
58 | const isParentObjectNode = parentNode !== undefined && isObjectSeaNode(parentNode);
59 | const isParentArrayNode = parentNode !== undefined && isArraySeaNode(parentNode);
60 |
61 | let segment = '';
62 |
63 | if (isObjectSeaNode(node)) {
64 | if (node.data.isRootNode) {
65 | segment = CURLY_ROOT_NODE_NAME;
66 | } else {
67 | const objectSegment = isNumber(node.data.arrayIndexForObject)
68 | ? encloseSquareBrackets(node.data.arrayIndexForObject)
69 | : '';
70 |
71 | if (isParentObjectNode) {
72 | const propertyK = getObjectPropertyK({ edges, source: parentNode.id, target: node.id }) as string;
73 | segment = `.${propertyK}${objectSegment}`;
74 | }
75 |
76 | if (isParentArrayNode) {
77 | segment = objectSegment;
78 | }
79 | }
80 | }
81 |
82 | if (isArraySeaNode(node)) {
83 | const arraySegment = encloseSquareBrackets(node.data.arrayIndex); // e.g. `[0]`, `[32]`, `[128]`, ....
84 |
85 | if (node.data.isRootNode) {
86 | segment = CURLY_ROOT_NODE_NAME;
87 | } else {
88 | if (isParentObjectNode) {
89 | const propertyK = getObjectPropertyK({ edges, source: parentNode.id, target: node.id }) as string;
90 | segment = `.${propertyK}${arraySegment}`;
91 | }
92 |
93 | if (isParentArrayNode) {
94 | segment = arraySegment;
95 | }
96 | }
97 | }
98 |
99 | if (isPrimitiveSeaNode(node)) {
100 | const primitiveSegment = encloseSquareBrackets(node.data.arrayIndex); // e.g. `[0]`, `[32]`, `[128]`, ....
101 |
102 | if (isParentObjectNode) {
103 | const propertyK = getObjectPropertyK({ edges, source: parentNode.id, target: node.id }) as string;
104 | segment = `.${propertyK}${primitiveSegment}`;
105 | }
106 |
107 | if (isParentArrayNode) {
108 | segment = primitiveSegment;
109 | }
110 | }
111 |
112 | return `${segment}${acc}`;
113 | }, '');
114 |
115 | return {
116 | fullNodePath, // e.g. `{root}.array[3][2].field_3[4]`
117 | selfNodePath: fullNodePath.split('.').pop() ?? '', // e.g. `field_3[4]`
118 | };
119 | };
120 |
121 | export const useNodePath = (nodeId: string): NodePath => {
122 | const jsonTree = useJsonEngineStore((state) => state.jsonTree);
123 |
124 | return getNodePath(jsonTree, nodeId);
125 | };
126 |
--------------------------------------------------------------------------------
/src/node-detail/object/components/InferredDataTypeText.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useMemo } from 'react';
2 | import { DataTypeText } from '../../components/DataTypeText';
3 | import { InferredDataType } from '../enums/inferred-data-type.enum';
4 |
5 | type Props = {
6 | dataType: InferredDataType;
7 | };
8 |
9 | const _InferredDataTypeText = ({ dataType }: Props) => {
10 | const inferredDataTypeToTextMap: Record = useMemo(
11 | () => ({
12 | [InferredDataType.LatLngMap]: 'inferred/latLng',
13 | }),
14 | [],
15 | );
16 |
17 | return ;
18 | };
19 |
20 | export const InferredDataTypeText = memo(_InferredDataTypeText);
21 |
--------------------------------------------------------------------------------
/src/node-detail/object/components/InferredDetailCard.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardBody, CardHeader } from '@nextui-org/card';
2 | import { ForwardedRef, Fragment, forwardRef, memo, useCallback } from 'react';
3 | import { InferredDataType } from '../enums/inferred-data-type.enum';
4 | import { InferredDataTypeText } from './InferredDataTypeText';
5 | import { PropertyKeyChip } from './PropertyKeyChip';
6 |
7 | type Props = {
8 | propertyKeys: string[];
9 | inferredDataType: InferredDataType;
10 | children: React.ReactNode;
11 | };
12 |
13 | const _InferredDetailCard = (
14 | { propertyKeys, inferredDataType, children }: Props,
15 | ref: ForwardedRef,
16 | ) => {
17 | const renderPropertyKeyChips = useCallback(() => {
18 | return propertyKeys.map((propertyK: string, index: number, selfArray: string[]) => {
19 | const isLast: boolean = index === selfArray.length - 1;
20 |
21 | return (
22 |
23 |
24 | {!isLast && ` + `}
25 |
26 | );
27 | });
28 | }, [propertyKeys]);
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
36 |
{renderPropertyKeyChips()}
37 |
38 |
39 |
40 |
41 | {children}
42 |
43 | );
44 | };
45 |
46 | export const InferredDetailCard = memo(forwardRef(_InferredDetailCard));
47 |
--------------------------------------------------------------------------------
/src/node-detail/object/components/InferredLatLngMapCard.tsx:
--------------------------------------------------------------------------------
1 | import { LatLngTuple } from 'leaflet';
2 | import dynamic from 'next/dynamic';
3 | import { ComponentProps, memo } from 'react';
4 | import { useHoverNodeDetails } from '../../../store/node-detail-view/hooks/useHoverNodeDetails';
5 | import { PropertyValueTable } from '../../primitive/components/PropertyValueTable';
6 | import { InferredDataType } from '../enums/inferred-data-type.enum';
7 | import { InferredDetailCard } from './InferredDetailCard';
8 |
9 | const DynamicLeafletMap = dynamic(() => import('./LeafletMap'), {
10 | ssr: false,
11 | });
12 |
13 | type Props = {
14 | nodeId: string;
15 | latPropertyK: string;
16 | lngPropertyK: string;
17 | latLng: LatLngTuple;
18 | };
19 |
20 | const _InferredLatLngMapCard = ({ nodeId, latPropertyK, lngPropertyK, latLng }: Props) => {
21 | const { cardRef } = useHoverNodeDetails([
22 | { nodeId, propertyK: latPropertyK },
23 | { nodeId, propertyK: lngPropertyK },
24 | ]);
25 |
26 | return (
27 |
32 |
33 |
34 |
47 |
48 | );
49 | };
50 |
51 | export const InferredLatLngMapCard = memo(_InferredLatLngMapCard);
52 | export type InferredLatLngMapCardProps = ComponentProps;
53 |
--------------------------------------------------------------------------------
/src/node-detail/object/components/LeafletMap.tsx:
--------------------------------------------------------------------------------
1 | import L, { LatLngTuple } from 'leaflet';
2 | import 'leaflet/dist/leaflet.css';
3 | import { memo, useCallback, useEffect, useRef } from 'react';
4 |
5 | type Props = {
6 | latLng: LatLngTuple;
7 | };
8 |
9 | const _LeafletMap = ({ latLng }: Props) => {
10 | const leafletMapRef = useRef(null);
11 |
12 | useEffect(() => {
13 | L.Marker.prototype.setIcon(
14 | L.icon({
15 | iconUrl: '/map-marker.png',
16 | }),
17 | );
18 | }, []);
19 |
20 | const isLeafletInitialized = useCallback((leafletMapElement: HTMLDivElement): boolean => {
21 | const mapContainer: HTMLElement | null = L.DomUtil.get(leafletMapElement);
22 | return (mapContainer as any)._leaflet_id !== undefined;
23 | }, []);
24 |
25 | const addTileLayer = useCallback((leafletMap: L.Map) => {
26 | const attribution: string = '© OpenStreetMap';
27 |
28 | L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
29 | attribution,
30 | }).addTo(leafletMap);
31 | }, []);
32 |
33 | useEffect(() => {
34 | if (!leafletMapRef.current) return;
35 |
36 | const leafletMapElement: HTMLDivElement = leafletMapRef.current;
37 |
38 | if (isLeafletInitialized(leafletMapElement)) {
39 | return;
40 | }
41 |
42 | const leafletMap: L.Map = L.map(leafletMapElement, {
43 | center: latLng,
44 | zoom: 12,
45 | preferCanvas: true,
46 | });
47 |
48 | addTileLayer(leafletMap);
49 | L.marker(latLng).addTo(leafletMap);
50 |
51 | return () => {
52 | leafletMap.off();
53 | leafletMap.remove();
54 | };
55 | }, [latLng, isLeafletInitialized, addTileLayer]);
56 |
57 | return ;
58 | };
59 |
60 | export default memo(_LeafletMap);
61 |
--------------------------------------------------------------------------------
/src/node-detail/object/components/ObjectNodeDetail.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment, memo, useCallback } from 'react';
2 | import { NodeType } from '../../../store/json-engine/enums/node-type.enum';
3 | import { ObjectNodeData } from '../../../store/json-engine/types/sea-node.type';
4 | import { isEmptyObject } from '../../../utils/object.util';
5 | import { EmptyNodeMessage } from '../../components/EmptyNodeMessage';
6 | import { NodeDetailList } from '../../components/NodeDetailList';
7 | import { inferMap } from '../helpers/infer-map.helper';
8 | import { InferredLatLngMapCard, InferredLatLngMapCardProps } from './InferredLatLngMapCard';
9 | import { PropertyCard } from './PropertyCard';
10 |
11 | type Props = {
12 | nodeId: string;
13 | nodeData: ObjectNodeData;
14 | };
15 |
16 | const _ObjectNodeDetail = ({ nodeId, nodeData }: Props) => {
17 | const renderPropertyCards = useCallback(() => {
18 | const InferredLatLngMapCardProps: InferredLatLngMapCardProps[] = inferMap(nodeData.obj, nodeId);
19 |
20 | return Object.entries(nodeData.obj).map(([propertyK, propertyV]) => {
21 | // Compare with `lngPropertyK` in order to insert `InferredMapCard` after kind of longitude property.
22 | const inferredMapCardPropsIndex: number = InferredLatLngMapCardProps.findIndex(
23 | ({ lngPropertyK }) => lngPropertyK === propertyK
24 | );
25 | const shouldInsert: boolean = inferredMapCardPropsIndex !== -1;
26 |
27 | return (
28 |
29 |
30 | {shouldInsert && }
31 |
32 | );
33 | });
34 | }, [nodeId, nodeData]);
35 |
36 | return (
37 |
38 | {isEmptyObject(nodeData.obj) ? : renderPropertyCards()}
39 |
40 | );
41 | };
42 |
43 | export const ObjectNodeDetail = memo(_ObjectNodeDetail);
44 |
--------------------------------------------------------------------------------
/src/node-detail/object/components/PropertyCard.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useMemo } from 'react';
2 | import { useJsonEngineStore } from '../../../store/json-engine/json-engine.store';
3 | import { useHoverNodeDetails } from '../../../store/node-detail-view/hooks/useHoverNodeDetails';
4 | import { isObject } from '../../../utils/json.util';
5 | import { NodeDetailCard } from '../../components/NodeDetailCard';
6 | import { PropertyKeyChip } from './PropertyKeyChip';
7 |
8 | type Props = {
9 | nodeId: string;
10 | propertyK: string;
11 | propertyV: any;
12 | };
13 |
14 | const _PropertyCard = ({ nodeId, propertyK, propertyV }: Props) => {
15 | const { edges } = useJsonEngineStore((state) => state.jsonTree);
16 |
17 | const { cardRef } = useHoverNodeDetails([{ nodeId, propertyK }]);
18 |
19 | const childObjectNodeId: string | null = useMemo(() => {
20 | if (!isObject(propertyV)) {
21 | return null;
22 | }
23 |
24 | return edges.find(({ source, sourceHandle }) => source === nodeId && sourceHandle === propertyK)?.target ?? null;
25 | }, [edges, nodeId, propertyK, propertyV]);
26 |
27 | return (
28 | }
31 | value={propertyV}
32 | childObjectNodeId={childObjectNodeId}
33 | />
34 | );
35 | };
36 |
37 | export const PropertyCard = memo(_PropertyCard);
38 |
--------------------------------------------------------------------------------
/src/node-detail/object/components/PropertyKeyChip.tsx:
--------------------------------------------------------------------------------
1 | import { Chip } from '@nextui-org/chip';
2 | import { memo } from 'react';
3 |
4 | type Props = {
5 | value: string;
6 | inferred?: boolean;
7 | };
8 |
9 | const _PropertyKeyChip = ({ value, inferred = false }: Props) => {
10 | return (
11 |
12 | {value}
13 |
14 | );
15 | };
16 |
17 | export const PropertyKeyChip = memo(_PropertyKeyChip);
18 |
--------------------------------------------------------------------------------
/src/node-detail/object/enums/inferred-data-type.enum.ts:
--------------------------------------------------------------------------------
1 | export enum InferredDataType {
2 | LatLngMap = 'lat-lng-map',
3 | }
4 |
--------------------------------------------------------------------------------
/src/node-detail/object/helpers/infer-map.helper.ts:
--------------------------------------------------------------------------------
1 | import { isNumber } from '../../../utils/json.util';
2 | import { InferredLatLngMapCardProps } from '../components/InferredLatLngMapCard';
3 |
4 | const convertInferredLatLngMapCardProps = (
5 | obj: object,
6 | nodeId: string,
7 | propertyKeys: [string, string]
8 | ): InferredLatLngMapCardProps => {
9 | const [latPropertyK, lngPropertyK] = propertyKeys;
10 |
11 | return {
12 | nodeId,
13 | latPropertyK,
14 | lngPropertyK,
15 | latLng: [(obj as any)[latPropertyK], (obj as any)[lngPropertyK]],
16 | };
17 | };
18 |
19 | type InferMapCase = {
20 | latWord: string;
21 | lngWords: string[];
22 | };
23 |
24 | export const inferMap = (obj: object, nodeId: string): InferredLatLngMapCardProps[] => {
25 | const inferMapCases: Record = {
26 | // Lowercase
27 | lowercase1: {
28 | latWord: 'latitude',
29 | lngWords: ['longitude'],
30 | },
31 | lowercase2: {
32 | latWord: 'lat',
33 | lngWords: ['lng', 'long'],
34 | },
35 | // Uppercase
36 | uppercase1: {
37 | latWord: 'LATITUDE',
38 | lngWords: ['LONGITUDE'],
39 | },
40 | uppercase2: {
41 | latWord: 'LAT',
42 | lngWords: ['LNG', 'LONG'],
43 | },
44 | };
45 |
46 | let result: InferredLatLngMapCardProps[] = [];
47 |
48 | for (let caseKey in inferMapCases) {
49 | const { latWord, lngWords } = inferMapCases[caseKey];
50 |
51 | if (isNumber((obj as any)[latWord])) {
52 | for (let lngWord of lngWords) {
53 | if (isNumber((obj as any)[lngWord])) {
54 | result.push(convertInferredLatLngMapCardProps(obj, nodeId, [latWord, lngWord]));
55 | break;
56 | }
57 | }
58 | }
59 | }
60 |
61 | return result;
62 | };
63 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/AudioViewer.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { AudioSrc } from '../types/media-src.type';
3 | import { MediaViewerBox } from './MediaViewerBox';
4 | import { MIMETypeAndSize } from './MIMETypeAndSize';
5 |
6 | type Props = {
7 | audioSrc: AudioSrc;
8 | };
9 |
10 | const _AudioViewer = ({ audioSrc }: Props) => {
11 | return (
12 |
13 |
14 |
15 |
16 | );
17 | };
18 |
19 | export const AudioViewer = memo(_AudioViewer);
20 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/Calendar.tsx:
--------------------------------------------------------------------------------
1 | import { format, getDate, getDaysInMonth, startOfMonth } from 'date-fns';
2 | import { memo } from 'react';
3 | import { Text } from '../../../ui/components/Text';
4 |
5 | type Props = {
6 | date: Date;
7 | };
8 |
9 | const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
10 |
11 | const _Calendar = ({ date }: Props) => {
12 | const firstDateOfMonth = startOfMonth(date);
13 | const firstDayColumnStart = firstDateOfMonth.getDay() + 1;
14 |
15 | const daysInMonth = getDaysInMonth(date);
16 | const days = Array.from({ length: daysInMonth }, (v, i) => i + 1); // If `daysInMonth` is 28, then [1, 2, 3, ..., 28]
17 |
18 | return (
19 |
20 |
21 |
22 | {format(date, 'MMM yyyy')} {/* e.g. `Jan 2023` */}
23 |
24 |
25 | ({format(date, 'OOOO')}) {/* e.g. `(GMT+09:00)` */}
26 |
27 |
28 |
29 |
30 | {WEEKDAYS.map((weekday) => (
31 | -
32 | {weekday}
33 |
34 | ))}
35 |
36 |
37 |
38 | {days.map((day) => {
39 | const isGivenDate = day === getDate(date);
40 |
41 | return (
42 | -
47 | {day}
48 |
49 | );
50 | })}
51 |
52 |
53 | );
54 | };
55 |
56 | export const Calendar = memo(_Calendar);
57 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/ImageViewer.tsx:
--------------------------------------------------------------------------------
1 | import { Image } from '@nextui-org/image';
2 | import { memo } from 'react';
3 | import { openLinkAsNewTab } from '../../../utils/window.util';
4 | import { ImageSrc } from '../types/media-src.type';
5 | import { MediaViewerBox } from './MediaViewerBox';
6 | import { MIMETypeAndSize } from './MIMETypeAndSize';
7 |
8 | type Props = {
9 | imageSrc: ImageSrc;
10 | };
11 |
12 | const _ImageViewer = ({ imageSrc }: Props) => {
13 | return (
14 |
15 | openLinkAsNewTab(imageSrc)}
23 | />
24 |
25 |
26 | );
27 | };
28 |
29 | export const ImageViewer = memo(_ImageViewer);
30 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/LinkViewer.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { useJsonLinkApi } from '../../../api/json-link-api/useJsonLinkApi';
3 | import { isNull } from '../../../utils/json.util';
4 | import { HttpUri } from '../types/http-uri.type';
5 | import { PreviewOgMeta } from './PreviewOgMeta';
6 |
7 | type Props = {
8 | httpUri: HttpUri;
9 | };
10 |
11 | const _LinkViewer = ({ httpUri }: Props) => {
12 | const { data } = useJsonLinkApi({ httpUri });
13 |
14 | if (isNull(data) || data === undefined) {
15 | return null;
16 | }
17 |
18 | return ;
19 | };
20 |
21 | export const LinkViewer = memo(_LinkViewer);
22 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/MIMETypeAndSize.tsx:
--------------------------------------------------------------------------------
1 | import prettyBytes from 'pretty-bytes';
2 | import { memo } from 'react';
3 | import { useMediaHeadApi } from '../../../api/media-head-api/useMediaHeadApi';
4 | import { Text } from '../../../ui/components/Text';
5 | import { isNull, isNumber } from '../../../utils/json.util';
6 | import { AudioSrc, ImageSrc, VideoSrc } from '../types/media-src.type';
7 |
8 | type Props = {
9 | mediaSrc: ImageSrc | AudioSrc | VideoSrc;
10 | };
11 |
12 | const _MIMETypeAndSize = ({ mediaSrc }: Props) => {
13 | const { isSuccess, data } = useMediaHeadApi({ mediaSrc });
14 |
15 | if (!isSuccess || (isNull(data.mimeType) && isNull(data.mimeBytes))) {
16 | return null;
17 | }
18 |
19 | return (
20 |
21 | {data.mimeType}
22 | {isNumber(data.mimeBytes) ? prettyBytes(data.mimeBytes) : ''}
23 |
24 | );
25 | };
26 |
27 | export const MIMETypeAndSize = memo(_MIMETypeAndSize);
28 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/MediaViewerBox.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 |
3 | type Props = {
4 | children: React.ReactNode;
5 | };
6 |
7 | const _MediaViewerBox = ({ children }: Props) => {
8 | return {children}
;
9 | };
10 |
11 | export const MediaViewerBox = memo(_MediaViewerBox);
12 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/NumberInspector.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { Text } from '../../../ui/components/Text';
3 | import { useIntlNumberFormat } from '../hooks/useIntlNumberFormat';
4 | import { TextCopyBox } from './TextCopyBox';
5 |
6 | type Props = {
7 | value: number;
8 | };
9 |
10 | const _NumberInspector = ({ value }: Props) => {
11 | const { intlNumberFormat } = useIntlNumberFormat();
12 |
13 | return (
14 | <>
15 |
16 | {intlNumberFormat.format(value)}
17 | >
18 | );
19 | };
20 |
21 | export const NumberInspector = memo(_NumberInspector);
22 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PreviewAudio.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { Base64AudioDataUri } from '../types/media-src.type';
3 | import { AudioViewer } from './AudioViewer';
4 |
5 | type Props = {
6 | audioSrc: Base64AudioDataUri;
7 | };
8 |
9 | const _PreviewAudio = ({ audioSrc }: Props) => {
10 | return ;
11 | };
12 |
13 | export const PreviewAudio = memo(_PreviewAudio);
14 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PreviewAudioUri.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { HttpUri } from '../types/http-uri.type';
3 | import { AudioViewer } from './AudioViewer';
4 | import { UriTable } from './UriTable';
5 |
6 | type Props = {
7 | audioUri: HttpUri;
8 | };
9 |
10 | const _PreviewAudioUri = ({ audioUri }: Props) => {
11 | return (
12 | <>
13 |
14 |
15 | >
16 | );
17 | };
18 |
19 | export const PreviewAudioUri = memo(_PreviewAudioUri);
20 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PreviewColor.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 |
3 | type Props = {
4 | color: string;
5 | };
6 |
7 | const _PreviewColor = ({ color }: Props) => {
8 | return ;
9 | };
10 |
11 | export const PreviewColor = memo(_PreviewColor);
12 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PreviewDatetime.tsx:
--------------------------------------------------------------------------------
1 | import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@nextui-org/table';
2 | import { addMinutes, format } from 'date-fns';
3 | import { Key, memo, useEffect, useState } from 'react';
4 | import { Calendar } from './Calendar';
5 | import { PROPERTY_VALUE_TABLE_COLUMNS, PropertyValueTableColumnKey } from './PropertyValueTable';
6 | import { RelativeTimeFormatter } from './RelativeTimeFormatter';
7 |
8 | type Props = {
9 | datetime: string;
10 | };
11 |
12 | type PropertyName = 'Unix(ms)' | 'Unix(s)' | 'GMT' | 'Your timezone' | 'Relative';
13 |
14 | const PROPERTY_NAMES: PropertyName[] = ['Unix(ms)', 'Unix(s)', 'GMT', 'Your timezone', 'Relative'];
15 |
16 | type Row = {
17 | [PropertyValueTableColumnKey.Property]: PropertyName;
18 | [PropertyValueTableColumnKey.Value]: string | number;
19 | };
20 |
21 | const _PreviewDatetime = ({ datetime }: Props) => {
22 | const [rows, setRows] = useState([]);
23 |
24 | useEffect(() => {
25 | const date: Date = new Date(datetime);
26 | const gmtDate: Date = addMinutes(date, date.getTimezoneOffset());
27 |
28 | const formatWithoutGMT: string = 'EEE, MMM d, yyyy, HH:mm:ss';
29 | const formatWithGMT: string = `${formatWithoutGMT} OOOO`;
30 |
31 | const unixTimestamp = date.getTime(); // (ms)
32 |
33 | const map: Record = {
34 | 'Unix(ms)': unixTimestamp,
35 | 'Unix(s)': Math.floor(unixTimestamp / 1000),
36 | GMT: format(gmtDate, formatWithoutGMT).concat(' GMT+00:00'),
37 | 'Your timezone': format(date, formatWithGMT),
38 | Relative: unixTimestamp,
39 | };
40 |
41 | const rows: Row[] = PROPERTY_NAMES.map(
42 | (propertyName) =>
43 | ({
44 | property: propertyName,
45 | value: map[propertyName],
46 | }) as Row,
47 | );
48 |
49 | setRows(rows);
50 | }, [datetime]);
51 |
52 | return (
53 | <>
54 |
55 |
56 |
65 |
66 | {({ key, label }) => {label}}
67 |
68 |
69 |
70 | {(row) => (
71 |
72 | {(columnKey: Key) => {
73 | const isPropertyColumn = columnKey === PropertyValueTableColumnKey.Property;
74 | const isValueColumn = columnKey === PropertyValueTableColumnKey.Value;
75 |
76 | return (
77 |
80 | {row.property === 'Relative' && isValueColumn ? (
81 |
82 | ) : (
83 | row[columnKey as PropertyValueTableColumnKey]
84 | )}
85 |
86 | );
87 | }}
88 |
89 | )}
90 |
91 |
92 | >
93 | );
94 | };
95 |
96 | export const PreviewDatetime = memo(_PreviewDatetime);
97 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PreviewHttpUri.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { HttpUri } from '../types/http-uri.type';
3 | import { LinkViewer } from './LinkViewer';
4 | import { UriTable } from './UriTable';
5 |
6 | type Props = {
7 | httpUri: HttpUri;
8 | };
9 |
10 | const _PreviewHttpUri = ({ httpUri }: Props) => {
11 | return (
12 | <>
13 |
14 |
15 | >
16 | );
17 | };
18 |
19 | export const PreviewHttpUri = memo(_PreviewHttpUri);
20 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PreviewImage.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { Base64ImageDataUri, ImageSrc } from '../types/media-src.type';
3 | import { ImageViewer } from './ImageViewer';
4 |
5 | type Props = {
6 | imageSrc: Base64ImageDataUri;
7 | };
8 |
9 | const _PreviewImage = ({ imageSrc }: Props) => {
10 | return ;
11 | };
12 |
13 | export const PreviewImage = memo(_PreviewImage);
14 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PreviewImageUri.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { HttpUri } from '../types/http-uri.type';
3 | import { ImageViewer } from './ImageViewer';
4 | import { UriTable } from './UriTable';
5 |
6 | type Props = {
7 | imageUri: HttpUri;
8 | };
9 |
10 | const _PreviewImageUri = ({ imageUri }: Props) => {
11 | return (
12 | <>
13 |
14 |
15 | >
16 | );
17 | };
18 |
19 | export const PreviewImageUri = memo(_PreviewImageUri);
20 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PreviewOgMeta.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardBody, CardFooter } from '@nextui-org/card';
2 | import { Image } from '@nextui-org/image';
3 | import { memo } from 'react';
4 | import { JsonLink } from '../../../api/json-link-api/json-link.types';
5 | import { Text } from '../../../ui/components/Text';
6 | import { isString } from '../../../utils/json.util';
7 | import { openLinkAsNewTab } from '../../../utils/window.util';
8 |
9 | type Props = {
10 | jsonLink: JsonLink;
11 | };
12 |
13 | const _PreviewOgMeta = ({ jsonLink }: Props) => {
14 | const { title, description, images = [] } = jsonLink;
15 |
16 | if (!isString(title) && !isString(description)) {
17 | return null;
18 | }
19 |
20 | return (
21 | openLinkAsNewTab(jsonLink.url)}>
22 | {images.length > 0 && (
23 |
24 |
25 |
26 | )}
27 |
28 |
29 |
30 | {title as string}
31 |
32 |
33 |
37 | {description as string}
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | export const PreviewOgMeta = memo(_PreviewOgMeta);
45 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PreviewVideo.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { Base64VideoDataUri } from '../types/media-src.type';
3 | import { VideoViewer } from './VideoViewer';
4 |
5 | type Props = {
6 | videoSrc: Base64VideoDataUri;
7 | };
8 |
9 | const _PreviewVideo = ({ videoSrc }: Props) => {
10 | return ;
11 | };
12 |
13 | export const PreviewVideo = memo(_PreviewVideo);
14 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PreviewVideoUri.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { HttpUri } from '../types/http-uri.type';
3 | import { VideoViewer } from './VideoViewer';
4 | import { UriTable } from './UriTable';
5 |
6 | type Props = {
7 | videoUri: HttpUri;
8 | };
9 |
10 | const _PreviewVideoUri = ({ videoUri }: Props) => {
11 | return (
12 | <>
13 |
14 |
15 | >
16 | );
17 | };
18 |
19 | export const PreviewVideoUri = memo(_PreviewVideoUri);
20 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PrimitiveNodeDetail.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { PrimitiveNodeData } from '../../../store/json-engine/types/sea-node.type';
3 | import { ArrayItemPrimitiveCard } from '../../array/components/ArrayItemPrimitiveCard';
4 |
5 | type Props = {
6 | nodeId: string;
7 | nodeData: PrimitiveNodeData;
8 | };
9 |
10 | const _PrimitiveNodeDetail = ({ nodeId, nodeData }: Props) => {
11 | const { arrayIndex, value } = nodeData;
12 |
13 | return ;
14 | };
15 |
16 | export const PrimitiveNodeDetail = memo(_PrimitiveNodeDetail);
17 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/PropertyValueTable.tsx:
--------------------------------------------------------------------------------
1 | import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@nextui-org/table';
2 | import { Key, memo } from 'react';
3 |
4 | type Props = {
5 | rows: PropertyValueTableRow[];
6 | ariaLabel: string; // To resolve warning message.
7 | };
8 |
9 | export enum PropertyValueTableColumnKey {
10 | Property = 'property',
11 | Value = 'value',
12 | }
13 |
14 | export type PropertyValueTableRow = {
15 | [P in PropertyValueTableColumnKey]: string | number;
16 | };
17 |
18 | export const PROPERTY_VALUE_TABLE_COLUMNS = [
19 | {
20 | key: PropertyValueTableColumnKey.Property,
21 | label: 'PROPERTY',
22 | },
23 | {
24 | key: PropertyValueTableColumnKey.Value,
25 | label: 'VALUE',
26 | },
27 | ];
28 |
29 | const _PropertyValueTable = ({ rows, ariaLabel }: Props) => {
30 | return (
31 |
40 |
41 | {({ key, label }) => {label}}
42 |
43 |
44 |
45 | {(row) => (
46 |
47 | {(columnKey: Key) => {
48 | const isPropertyColumn = columnKey === PropertyValueTableColumnKey.Property;
49 |
50 | return (
51 |
54 | {row[columnKey as PropertyValueTableColumnKey]}
55 |
56 | );
57 | }}
58 |
59 | )}
60 |
61 |
62 | );
63 | };
64 |
65 | export const PropertyValueTable = memo(_PropertyValueTable);
66 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/RelativeTimeFormatter.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { format } from 'timeago.js';
3 | import { useIntervallyForceUpdate } from '../../../utils/react-hooks/useIntervallyForceUpdate';
4 |
5 | type Props = {
6 | unixTimestamp: number; // (ms)
7 | };
8 |
9 | const _RelativeTimeFormatter = ({ unixTimestamp }: Props) => {
10 | useIntervallyForceUpdate(1000);
11 |
12 | return <>{format(unixTimestamp)}>;
13 | };
14 |
15 | export const RelativeTimeFormatter = memo(_RelativeTimeFormatter);
16 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/StringInspector.tsx:
--------------------------------------------------------------------------------
1 | import { CircularProgress } from '@nextui-org/progress';
2 | import { memo } from 'react';
3 | import { encloseDoubleQuote } from '../../../utils/string.util';
4 | import { StringSubtypeValidator } from '../helpers/string-subtype.helper';
5 | import { HttpUri } from '../types/http-uri.type';
6 | import { Base64AudioDataUri, Base64ImageDataUri, Base64VideoDataUri } from '../types/media-src.type';
7 | import { PreviewAudio } from './PreviewAudio';
8 | import { PreviewAudioUri } from './PreviewAudioUri';
9 | import { PreviewColor } from './PreviewColor';
10 | import { PreviewDatetime } from './PreviewDatetime';
11 | import { PreviewHttpUri } from './PreviewHttpUri';
12 | import { PreviewImage } from './PreviewImage';
13 | import { PreviewImageUri } from './PreviewImageUri';
14 | import { PreviewVideo } from './PreviewVideo';
15 | import { PreviewVideoUri } from './PreviewVideoUri';
16 | import { TextCopyBox } from './TextCopyBox';
17 |
18 | type Props = {
19 | stringSubtypeValidator: StringSubtypeValidator;
20 | value: string;
21 | isLoading: boolean;
22 | };
23 |
24 | const _StringInspector = ({ stringSubtypeValidator, value, isLoading }: Props) => {
25 | const { isColor, isDatetime, isEmail, isHttpUri, isImage, isImageUri, isAudio, isAudioUri, isVideo, isVideoUri } =
26 | stringSubtypeValidator;
27 |
28 | return (
29 | <>
30 |
31 |
32 | {isLoading ? (
33 |
34 | ) : (
35 | <>
36 | {isColor && }
37 | {isDatetime && }
38 | {isHttpUri && }
39 | {isImage && }
40 | {isImageUri && }
41 | {isAudio && }
42 | {isAudioUri && }
43 | {isVideo && }
44 | {isVideoUri && }
45 | >
46 | )}
47 | >
48 | );
49 | };
50 |
51 | export const StringInspector = memo(_StringInspector);
52 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/TextCopyBox.tsx:
--------------------------------------------------------------------------------
1 | import { Chip } from '@nextui-org/chip';
2 | import { memo, useCallback } from 'react';
3 | import { Text } from '../../../ui/components/Text';
4 | import { isNull } from '../../../utils/json.util';
5 | import { useCopyToClipboard } from '../../../utils/react-hooks/useCopyToClipboard';
6 | import { useHover } from '../../../utils/react-hooks/useHover';
7 |
8 | type Props = {
9 | text: string;
10 | };
11 |
12 | const _TextCopyBox = ({ text }: Props) => {
13 | const [hostRef, isHostHovered] = useHover();
14 | const { copiedText, copyToClipboard } = useCopyToClipboard();
15 |
16 | const copyText = useCallback(() => {
17 | copyToClipboard(text);
18 | }, [copyToClipboard, text]);
19 |
20 | return (
21 |
26 | {isHostHovered && (
27 |
33 | {isNull(copiedText) ? 'Copy?' : 'Copied!'}
34 |
35 | )}
36 |
37 | {text}
38 |
39 | );
40 | };
41 |
42 | export const TextCopyBox = memo(_TextCopyBox);
43 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/UriTable.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useCallback } from 'react';
2 | import { HttpUri } from '../types/http-uri.type';
3 | import { PropertyValueTable, PropertyValueTableRow } from './PropertyValueTable';
4 |
5 | type Props = {
6 | httpUri: HttpUri;
7 | };
8 |
9 | type URLProperty = keyof Pick<
10 | URL,
11 | 'href' | 'origin' | 'protocol' | 'hostname' | 'port' | 'pathname' | 'hash' | 'search'
12 | >;
13 |
14 | const DISPLAY_TARGET_URL_PROPERTIES: URLProperty[] = [
15 | 'href',
16 | 'origin',
17 | 'protocol',
18 | 'hostname',
19 | 'port',
20 | 'pathname',
21 | 'hash',
22 | 'search',
23 | ];
24 |
25 | const _UriTable = ({ httpUri }: Props) => {
26 | const getUriTableRows = useCallback((httpUri: HttpUri): PropertyValueTableRow[] => {
27 | const httpUrlObject: URL = new URL(httpUri);
28 |
29 | return DISPLAY_TARGET_URL_PROPERTIES.filter((urlProperty) => httpUrlObject[urlProperty].length > 0).map(
30 | (urlProperty) =>
31 | ({
32 | property: urlProperty,
33 | value: httpUrlObject[urlProperty],
34 | } as PropertyValueTableRow)
35 | );
36 | }, []);
37 |
38 | return ;
39 | };
40 |
41 | /**
42 | * @example
43 | * href : "http://www.naver.com:1234/hello#abc?query=test"
44 | * origin: 'http://www.naver.com:1234';
45 | * protocol: 'http:';
46 | * hostname: 'www.naver.com';
47 | * port: '1234';
48 | * pathname: '/hello';
49 | * hash: '#abc?query=test';
50 | * search: '?query=test';
51 | */
52 | export const UriTable = memo(_UriTable);
53 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/components/VideoViewer.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { VideoSrc } from '../types/media-src.type';
3 | import { MediaViewerBox } from './MediaViewerBox';
4 | import { MIMETypeAndSize } from './MIMETypeAndSize';
5 |
6 | type Props = {
7 | videoSrc: VideoSrc;
8 | };
9 |
10 | const _VideoViewer = ({ videoSrc }: Props) => {
11 | return (
12 |
13 |
14 |
15 |
16 | );
17 | };
18 |
19 | export const VideoViewer = memo(_VideoViewer);
20 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/constants/string-subtype.constant.ts:
--------------------------------------------------------------------------------
1 | import { StringSubtypeValidator } from '../helpers/string-subtype.helper';
2 |
3 | export const ALL_FALSE_STRING_SUBTYPE_VALIDATOR: StringSubtypeValidator = {
4 | isColor: false,
5 | isDatetime: false,
6 | isEmail: false,
7 | isHttpUri: false,
8 | isImage: false,
9 | isImageUri: false,
10 | isAudio: false,
11 | isAudioUri: false,
12 | isVideo: false,
13 | isVideoUri: false,
14 | };
15 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/enums/string-subtype.enum.ts:
--------------------------------------------------------------------------------
1 | export enum StringSubtype {
2 | Color = 'color',
3 | Datetime = 'datetime',
4 | Email = 'email',
5 | HttpUri = 'http-uri', // http or https
6 | Image = 'image',
7 | ImageUri = 'image-uri',
8 | Audio = 'audio',
9 | AudioUri = 'audio-uri',
10 | Video = 'video',
11 | VideoUri = 'video-uri',
12 | }
13 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/helpers/string-subtype.helper.ts:
--------------------------------------------------------------------------------
1 | import { ALL_FALSE_STRING_SUBTYPE_VALIDATOR } from '../constants/string-subtype.constant';
2 | import { StringSubtype } from '../enums/string-subtype.enum';
3 | import { HttpUri } from '../types/http-uri.type';
4 |
5 | /**
6 | * Invalid color value can't be assigned to `style.color` attribute.
7 | */
8 | const isValidColor = (dirtyColor: string): boolean => {
9 | const optionStyle = new Option().style;
10 | optionStyle.color = dirtyColor;
11 |
12 | return !!optionStyle.color;
13 | };
14 |
15 | const isValidDate = (dirtyDate: string): boolean => {
16 | return new Date(dirtyDate).toString() !== 'Invalid Date';
17 | };
18 |
19 | const isValidEmail = (dirtyEmail: string): boolean => {
20 | const emailRegex =
21 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
22 |
23 | return emailRegex.test(dirtyEmail.toLowerCase());
24 | };
25 |
26 | const isValidHttpUri = (dirtyHttpUri: string): dirtyHttpUri is HttpUri => {
27 | let url: URL | undefined;
28 |
29 | try {
30 | url = new URL(dirtyHttpUri);
31 | } catch (e) {
32 | return false;
33 | }
34 |
35 | return ['http:', 'https:'].includes(url.protocol);
36 | };
37 |
38 | const isValidImage = (dirtyImage: string, isHttpUri: boolean): Promise => {
39 | if (dirtyImage.startsWith('data:image/') || isHttpUri) {
40 | const img = new Image();
41 | img.src = dirtyImage;
42 |
43 | return new Promise((resolve) => {
44 | img.onerror = () => resolve(false);
45 | img.onload = () => resolve(true);
46 | });
47 | } else {
48 | return new Promise((resolve) => {
49 | resolve(false);
50 | });
51 | }
52 | };
53 |
54 | /**
55 | * HACK: For now, we consider a video whose width & height are both 0 to be audio.
56 | */
57 | const hasVideoDimensions = (target: HTMLVideoElement): boolean => {
58 | return target.videoWidth > 0 && target.videoHeight > 0;
59 | };
60 |
61 | const isValidVideo = (dirtyVideo: string, isHttpUri: boolean): Promise => {
62 | if (dirtyVideo.startsWith('data:video/') || isHttpUri) {
63 | const video = document.createElement('video');
64 | video.preload = 'metadata';
65 | video.src = dirtyVideo;
66 |
67 | return new Promise((resolve) => {
68 | video.onerror = () => resolve(false);
69 | video.onloadedmetadata = () => resolve(hasVideoDimensions(video));
70 | });
71 | } else {
72 | return new Promise((resolve) => {
73 | resolve(false);
74 | });
75 | }
76 | };
77 |
78 | const isValidAudio = (dirtyAudio: string, isHttpUri: boolean): Promise => {
79 | if (dirtyAudio.startsWith('data:audio/') || isHttpUri) {
80 | const audio = new Audio();
81 | audio.preload = 'metadata';
82 | audio.src = dirtyAudio;
83 |
84 | return new Promise((resolve) => {
85 | audio.onerror = () => resolve(false);
86 | audio.ondurationchange = () => resolve(true);
87 | });
88 | } else {
89 | return new Promise((resolve) => {
90 | resolve(false);
91 | });
92 | }
93 | };
94 |
95 | export type StringSubtypeValidator = { [P in keyof typeof StringSubtype as `is${P}`]: boolean };
96 |
97 | export const validateStringSubtype = async (v: string): Promise => {
98 | if (isValidColor(v)) {
99 | return {
100 | ...ALL_FALSE_STRING_SUBTYPE_VALIDATOR,
101 | isColor: true,
102 | };
103 | }
104 |
105 | if (isValidDate(v)) {
106 | return {
107 | ...ALL_FALSE_STRING_SUBTYPE_VALIDATOR,
108 | isDatetime: true,
109 | };
110 | }
111 |
112 | if (isValidEmail(v)) {
113 | return {
114 | ...ALL_FALSE_STRING_SUBTYPE_VALIDATOR,
115 | isEmail: true,
116 | };
117 | }
118 |
119 | const isHttpUri: boolean = isValidHttpUri(v);
120 |
121 | if (await isValidImage(v, isHttpUri)) {
122 | return {
123 | ...ALL_FALSE_STRING_SUBTYPE_VALIDATOR,
124 | isImageUri: isHttpUri,
125 | isImage: !isHttpUri,
126 | };
127 | }
128 |
129 | /**
130 | * @important
131 | * `isValidVideo` function should be called before `isValidAudio`.
132 | * Because video and audio are compatible with each otehr.
133 | */
134 | if (await isValidVideo(v, isHttpUri)) {
135 | return {
136 | ...ALL_FALSE_STRING_SUBTYPE_VALIDATOR,
137 | isVideoUri: isHttpUri,
138 | isVideo: !isHttpUri,
139 | };
140 | }
141 |
142 | if (await isValidAudio(v, isHttpUri)) {
143 | return {
144 | ...ALL_FALSE_STRING_SUBTYPE_VALIDATOR,
145 | isAudioUri: isHttpUri,
146 | isAudio: !isHttpUri,
147 | };
148 | }
149 |
150 | if (isHttpUri) {
151 | return {
152 | ...ALL_FALSE_STRING_SUBTYPE_VALIDATOR,
153 | isHttpUri,
154 | };
155 | }
156 |
157 | return ALL_FALSE_STRING_SUBTYPE_VALIDATOR;
158 | };
159 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/hooks/useIntlNumberFormat.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 |
5 | export const useIntlNumberFormat = () => {
6 | const [intlNumberFormat] = useState(
7 | Intl.NumberFormat(navigator.language, {
8 | maximumFractionDigits: 5,
9 | })
10 | );
11 |
12 | return {
13 | intlNumberFormat,
14 | };
15 | };
16 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/types/http-uri.type.ts:
--------------------------------------------------------------------------------
1 | export type HttpUri = `http${'s' | ''}:${string}`;
2 |
--------------------------------------------------------------------------------
/src/node-detail/primitive/types/media-src.type.ts:
--------------------------------------------------------------------------------
1 | import { HttpUri } from './http-uri.type';
2 |
3 | export type Base64ImageDataUri = `data:image/${string};base64,${string}`;
4 | // Transferring video to data uri is terrible way. So there are very few use cases.
5 | export type Base64VideoDataUri = `data:video/${string};base64,${string}`;
6 | export type Base64AudioDataUri = `data:audio/${string};base64,${string}`;
7 |
8 | export type ImageSrc = Base64ImageDataUri | HttpUri;
9 | export type VideoSrc = Base64VideoDataUri | HttpUri;
10 | export type AudioSrc = Base64AudioDataUri | HttpUri;
11 |
--------------------------------------------------------------------------------
/src/services/local-storage.service.ts:
--------------------------------------------------------------------------------
1 | import { isString } from '../utils/json.util';
2 |
3 | type LocalStorageKey = 'settings:minimap' | 'settings:nodePath';
4 |
5 | type KeyToValueTypeMap = {
6 | 'settings:minimap': boolean;
7 | 'settings:nodePath': boolean;
8 | }[K];
9 |
10 | type DefaultValueMap = {
11 | [K in LocalStorageKey]: KeyToValueTypeMap;
12 | };
13 |
14 | const defaultValueMap: DefaultValueMap = {
15 | 'settings:minimap': true,
16 | 'settings:nodePath': true,
17 | };
18 |
19 | export const localStorageService = {
20 | setItem: (key: K, value: KeyToValueTypeMap) => {
21 | if (typeof window !== 'undefined') {
22 | localStorage.setItem(key, JSON.stringify(value));
23 | }
24 | },
25 |
26 | getItem: (key: K): KeyToValueTypeMap => {
27 | if (typeof window !== 'undefined') {
28 | const storedValue: string | null = localStorage.getItem(key);
29 |
30 | if (isString(storedValue)) {
31 | return JSON.parse(storedValue) as KeyToValueTypeMap;
32 | } else {
33 | return defaultValueMap[key];
34 | }
35 | } else {
36 | return defaultValueMap[key];
37 | }
38 | },
39 |
40 | removeItem: (key: LocalStorageKey) => {
41 | if (typeof window !== 'undefined') {
42 | localStorage.removeItem(key);
43 | }
44 | },
45 | };
46 |
--------------------------------------------------------------------------------
/src/settings/components/SettingsModal.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Modal, ModalBody, ModalContent, ModalHeader } from '@nextui-org/modal';
4 | import { Switch } from '@nextui-org/switch';
5 | import { memo } from 'react';
6 | import { useSettingsStore } from '../../store/settings/settings.store';
7 |
8 | type Props = {
9 | isModalOpen: boolean;
10 | closeModal: () => void;
11 | };
12 |
13 | const _SettingsModal = ({ isModalOpen, closeModal }: Props) => {
14 | const [isMinimapOn, isNodePathOn, toggleMinimap, toggleNodePath] = useSettingsStore((state) => [
15 | state.isMinimapOn,
16 | state.isNodePathOn,
17 | state.toggleMinimap,
18 | state.toggleNodePath,
19 | ]);
20 |
21 | return (
22 |
23 |
24 | {(onClose) => (
25 | <>
26 | Settings
27 |
28 |
29 | Minimap
30 |
31 |
32 | Node Path
33 |
34 |
35 | >
36 | )}
37 |
38 |
39 | );
40 | };
41 |
42 | export const SettingsModal = memo(_SettingsModal);
43 |
--------------------------------------------------------------------------------
/src/store/json-diagram-view/json-diagram-view.store.ts:
--------------------------------------------------------------------------------
1 | import { Node } from 'reactflow';
2 | import { create } from 'zustand';
3 |
4 | type State = {
5 | selectedNodeId: string | null;
6 | };
7 |
8 | type Actions = {
9 | selectNode: (nodeId: Node['id']) => void;
10 | resetSelectedNode: () => void;
11 | resetJsonDiagramViewStore: () => void;
12 | };
13 |
14 | const initialState: State = {
15 | selectedNodeId: null,
16 | };
17 |
18 | export const useJsonDiagramViewStore = create((set) => ({
19 | ...initialState,
20 | selectNode: (nodeId: Node['id']) => set(() => ({ selectedNodeId: nodeId })),
21 | // TODO: Find out the way of calling `resetSelectedNode` when another store's state changed.
22 | resetSelectedNode: () => set(() => ({ selectedNodeId: initialState.selectedNodeId })),
23 | resetJsonDiagramViewStore: () => set(initialState),
24 | }));
25 |
--------------------------------------------------------------------------------
/src/store/json-editor-view/json-editor-view.store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | type State = {
4 | isJsonEditorVisible: boolean;
5 | };
6 |
7 | type Actions = {
8 | toggleJsonEditor: () => void;
9 | resetJsonEditorViewStore: () => void;
10 | };
11 |
12 | const initialState: State = {
13 | isJsonEditorVisible: true,
14 | };
15 |
16 | export const useJsonEditorViewStore = create((set, get) => ({
17 | ...initialState,
18 | toggleJsonEditor: () => {
19 | const prev = get().isJsonEditorVisible;
20 |
21 | set(() => ({
22 | isJsonEditorVisible: !prev,
23 | }));
24 | },
25 | resetJsonEditorViewStore: () => set(initialState),
26 | }));
27 |
--------------------------------------------------------------------------------
/src/store/json-engine/enums/edge-type.enum.ts:
--------------------------------------------------------------------------------
1 | export enum EdgeType {
2 | Default = 'default',
3 | Chain = 'chain', // Used to express connections between array items.
4 | }
5 |
--------------------------------------------------------------------------------
/src/store/json-engine/enums/json-data-type.enum.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * See the [JSON Data Types](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types)
3 | */
4 | export enum JsonDataType {
5 | // For `Object` node type
6 | Object = 'object',
7 |
8 | // For `Array` node type
9 | Array = 'array',
10 |
11 | // For `Primitive` node type
12 | String = 'string',
13 | Number = 'number',
14 | Boolean = 'boolean',
15 | Null = 'null',
16 | }
17 |
--------------------------------------------------------------------------------
/src/store/json-engine/enums/node-type.enum.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Primitive data type is different between Javascript and JSON areas.
3 | * @see https://www.w3schools.com/js/js_json_datatypes.asp
4 | */
5 | export enum NodeType {
6 | Object = 'object',
7 | Array = 'array',
8 |
9 | /**
10 | * Primitive node exists to express Array node items only.
11 | * It can be `string`, `number`, `boolean` or `null`. (`undefined` can't exist in JSON)
12 | */
13 | Primitive = 'primitive',
14 | }
15 |
--------------------------------------------------------------------------------
/src/store/json-engine/helpers/json-data-type.helper.ts:
--------------------------------------------------------------------------------
1 | import { isArray, isBoolean, isNull, isNumber, isObject, isString } from '../../../utils/json.util';
2 | import { JsonDataType } from '../enums/json-data-type.enum';
3 |
4 | export const validateJsonDataType = (
5 | v: unknown
6 | ): {
7 | [P in keyof typeof JsonDataType as `is${P}Data`]: boolean;
8 | } & {
9 | isPrimitiveData: boolean;
10 | } => {
11 | const isStringData: boolean = isString(v);
12 | const isNumberData: boolean = isNumber(v);
13 | const isBooleanData: boolean = isBoolean(v);
14 | const isNullData: boolean = isNull(v);
15 |
16 | return {
17 | isObjectData: isObject(v),
18 | isArrayData: isArray(v),
19 | isStringData,
20 | isNumberData,
21 | isBooleanData,
22 | isNullData,
23 | isPrimitiveData: isStringData || isNumberData || isBooleanData || isNullData,
24 | };
25 | };
26 |
27 | export const getJsonDataType = (v: unknown): JsonDataType => {
28 | const { isObjectData, isArrayData, isStringData, isNumberData, isBooleanData, isNullData } = validateJsonDataType(v);
29 |
30 | return isObjectData
31 | ? JsonDataType.Object
32 | : isArrayData
33 | ? JsonDataType.Array
34 | : isStringData
35 | ? JsonDataType.String
36 | : isNumberData
37 | ? JsonDataType.Number
38 | : isBooleanData
39 | ? JsonDataType.Boolean
40 | : isNullData
41 | ? JsonDataType.Null
42 | : JsonDataType.Null;
43 | };
44 |
--------------------------------------------------------------------------------
/src/store/json-engine/helpers/json-engine.helper.ts:
--------------------------------------------------------------------------------
1 | import { Entities, arrayToEntities } from '../../../utils/array.util';
2 | import { JsonTree } from '../json-engine.store';
3 | import { SeaNode } from '../types/sea-node.type';
4 | import { jsonParser } from './json-parser.helper';
5 | import { getLayoutedSeaNodes } from './sea-node-position.helper';
6 |
7 | export const convertJsonTree = (json: object | unknown[]): JsonTree => {
8 | const { seaNodes, edges } = jsonParser(json);
9 | const layoutedSeaNodes: SeaNode[] = getLayoutedSeaNodes(seaNodes, edges);
10 | const seaNodeEntities: Entities = arrayToEntities(layoutedSeaNodes, 'id');
11 |
12 | return { seaNodes: layoutedSeaNodes, seaNodeEntities, edges };
13 | };
14 |
--------------------------------------------------------------------------------
/src/store/json-engine/helpers/json-parser.helper.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from 'nanoid';
2 | import { Edge } from 'reactflow';
3 | import {
4 | ARRAY_ROOT_NODE_INDEX,
5 | ROOT_NODE_DEPTH,
6 | ROOT_PARENT_NODE_PATH_IDS,
7 | } from '../../../json-diagram/constants/root-node.constant';
8 | import { isLastItemOfArray } from '../../../utils/array.util';
9 | import { isArray, isObject, isString } from '../../../utils/json.util';
10 | import { EdgeType } from '../enums/edge-type.enum';
11 | import { JsonDataType } from '../enums/json-data-type.enum';
12 | import { NodeType } from '../enums/node-type.enum';
13 | import { ArraySeaNode, ObjectSeaNode, PrimitiveSeaNode, SeaNode } from '../types/sea-node.type';
14 | import { getJsonDataType, validateJsonDataType } from './json-data-type.helper';
15 | import { getXYPosition } from './sea-node-position.helper';
16 |
17 | const formatNodeId = (nodeSequence: number): string => `n${nodeSequence}`;
18 |
19 | export const addPrefixChain = (v: string): string => `chain-${v}`;
20 |
21 | type BeforeObjectSeaNode = {
22 | nodeId: string;
23 | depth: number;
24 | obj: object;
25 | parentNodePathIds: string[];
26 | arrayIndexForObject: number | null;
27 | isRootNode: boolean;
28 | };
29 |
30 | const convertObjectToNode = ({
31 | nodeId,
32 | depth,
33 | obj,
34 | parentNodePathIds,
35 | arrayIndexForObject,
36 | isRootNode,
37 | }: BeforeObjectSeaNode): ObjectSeaNode => {
38 | return {
39 | id: nodeId,
40 | type: NodeType.Object,
41 | position: getXYPosition(depth),
42 | data: {
43 | depth,
44 | dataType: JsonDataType.Object,
45 | stringifiedJson: JSON.stringify(obj),
46 | parentNodePathIds,
47 | obj,
48 | arrayIndexForObject,
49 | isRootNode,
50 | },
51 | };
52 | };
53 |
54 | type BeforeArraySeaNode = {
55 | nodeId: string;
56 | depth: number;
57 | arrayIndex: number;
58 | items: any[];
59 | parentNodePathIds: string[];
60 | isRootNode: boolean;
61 | };
62 |
63 | const convertArrayToNode = ({
64 | nodeId,
65 | depth,
66 | arrayIndex,
67 | items,
68 | parentNodePathIds,
69 | isRootNode,
70 | }: BeforeArraySeaNode): ArraySeaNode => {
71 | return {
72 | id: nodeId,
73 | type: NodeType.Array,
74 | position: getXYPosition(depth),
75 | data: {
76 | depth,
77 | dataType: JsonDataType.Array,
78 | stringifiedJson: JSON.stringify(arrayIndex),
79 | parentNodePathIds,
80 | arrayIndex,
81 | items,
82 | isRootNode,
83 | },
84 | };
85 | };
86 |
87 | type BeforePrimitiveSeaNode = {
88 | nodeId: string;
89 | depth: number;
90 | arrayIndex: number;
91 | value: string | number | boolean | null;
92 | parentNodePathIds: string[];
93 | };
94 |
95 | const convertPrimitiveToNode = ({
96 | nodeId,
97 | depth,
98 | arrayIndex,
99 | value,
100 | parentNodePathIds,
101 | }: BeforePrimitiveSeaNode): PrimitiveSeaNode => {
102 | return {
103 | id: nodeId,
104 | type: NodeType.Primitive,
105 | position: getXYPosition(depth),
106 | data: {
107 | depth,
108 | dataType: getJsonDataType(value) as
109 | | JsonDataType.String
110 | | JsonDataType.Number
111 | | JsonDataType.Boolean
112 | | JsonDataType.Null,
113 | stringifiedJson: JSON.stringify(value),
114 | parentNodePathIds,
115 | arrayIndex,
116 | value,
117 | },
118 | };
119 | };
120 |
121 | type SourceTarget = Pick;
122 | type DefaultEdgeParams = SourceTarget & Pick;
123 |
124 | const createDefaultEdge = ({ source, target, sourceHandle }: DefaultEdgeParams): Edge => {
125 | return {
126 | /**
127 | * @bugfix If the same edge id remains in `JsonDiagram` after update, the following bug occurs.
128 | * https://stackoverflow.com/questions/70114700/react-flow-renderer-edges-remain-in-ui-without-any-parents
129 | * @solution Use `nanoid()` for id.
130 | */
131 | id: nanoid(),
132 | type: 'default',
133 | source,
134 | target,
135 | sourceHandle,
136 | animated: true,
137 | style: {
138 | strokeWidth: 2,
139 | },
140 | };
141 | };
142 |
143 | const createChainEdge = ({ source, target }: SourceTarget): Edge => {
144 | return {
145 | id: nanoid(),
146 | type: EdgeType.Chain,
147 | source,
148 | target,
149 | sourceHandle: addPrefixChain(source),
150 | targetHandle: addPrefixChain(target),
151 | };
152 | };
153 |
154 | type TraverseParams = {
155 | traverseTarget: object | any[];
156 | depth: number;
157 | arrayIndexForObject: number | null;
158 | sourceSet: { source?: string; sourceHandle?: string };
159 | parentNodePathIds: string[];
160 | };
161 | type TraverseObjectParams = {
162 | _obj: object;
163 | _nextDepth: number;
164 | _parentNodePathIds: string[];
165 | _source: string;
166 | _sourceHandle: string;
167 | };
168 | type TraverseArrayParams = {
169 | _array: any[];
170 | _nextDepth: number;
171 | _parentNodePathIds: string[];
172 | _source: string;
173 | _sourceHandle?: string;
174 | };
175 |
176 | export const jsonParser = (
177 | json: object | any[],
178 | ): {
179 | seaNodes: SeaNode[];
180 | edges: Edge[];
181 | } => {
182 | /**
183 | * `nodeSequence` will be transformed to `nodeId`.
184 | */
185 | let nodeSequence: number = 0;
186 | let defaultEdges: Edge[] = [];
187 | let chainEdges: Edge[] = [];
188 |
189 | const addDefaultEdge = ({ source, target, sourceHandle }: DefaultEdgeParams): void => {
190 | defaultEdges = defaultEdges.concat(
191 | createDefaultEdge({
192 | source,
193 | target,
194 | sourceHandle,
195 | }),
196 | );
197 | };
198 |
199 | /**
200 | * 2023-01-30 Suprisingly, ChatGPT helps to refactor complex `traverse` code into smaller.
201 | *
202 | * `traverse` function follows `preorder traversal`
203 |
204 | * @implements
205 | * - if object
206 | * - add node(object)
207 | * - loop object
208 | * - if object field -> traverse
209 | * - if array field
210 | * - loop array field
211 | * - if object item -> traverse
212 | * - if array item -> add node(array) & traverse(if not empty)
213 | * - if primitive item -> add node(primitive)
214 | * - if array
215 | * - loop array
216 | * - if object item -> traverse
217 | * - if array item -> add node(array) & traverse(if not empty)
218 | * - if primitive item -> add node(primitive)
219 | *
220 | * @param sourceSet
221 | * - [source, sourceHandle]
222 | * - [undefined, undefined] -> No parent, {traverseTarget} is root node.
223 | * - [string, undefined] -> Parent is array node
224 | * - [string, string] -> Parent is object node (arrow is from object field)
225 | */
226 | const traverse = ({
227 | traverseTarget,
228 | depth,
229 | arrayIndexForObject,
230 | sourceSet,
231 | parentNodePathIds,
232 | }: TraverseParams): SeaNode[] => {
233 | let seaNodes: SeaNode[] = [];
234 |
235 | const traverseObject = ({ _obj, _nextDepth, _parentNodePathIds, _source, _sourceHandle }: TraverseObjectParams) => {
236 | nodeSequence++;
237 | const nextNodeId: string = formatNodeId(nodeSequence);
238 | const target: string = nextNodeId;
239 |
240 | seaNodes = seaNodes.concat(
241 | traverse({
242 | traverseTarget: _obj,
243 | depth: _nextDepth,
244 | arrayIndexForObject: null,
245 | sourceSet: {
246 | source: _source,
247 | sourceHandle: _sourceHandle,
248 | },
249 | parentNodePathIds: _parentNodePathIds,
250 | }),
251 | );
252 | addDefaultEdge({
253 | source: _source,
254 | target,
255 | sourceHandle: _sourceHandle,
256 | });
257 | };
258 |
259 | const traverseArray = ({ _array, _nextDepth, _parentNodePathIds, _source, _sourceHandle }: TraverseArrayParams) => {
260 | let sourceOfChainEdge: string | undefined;
261 |
262 | _array.forEach((arrayItem: any, arrayIndex: number, selfArray: any[]) => {
263 | const arrayItemValidator = validateJsonDataType(arrayItem);
264 |
265 | nodeSequence++;
266 | const nextNodeId = formatNodeId(nodeSequence);
267 | const target: string = nextNodeId;
268 |
269 | /**
270 | * If an array has multiple items, a chain edge should be added.
271 | * The chain edge is a blue-dash line which connects between first and last item of array.
272 | */
273 | if (selfArray.length > 1) {
274 | if (arrayIndex === 0) {
275 | sourceOfChainEdge = target;
276 | }
277 |
278 | if (isLastItemOfArray(arrayIndex, selfArray) && isString(sourceOfChainEdge)) {
279 | chainEdges = chainEdges.concat(
280 | createChainEdge({
281 | source: sourceOfChainEdge,
282 | target,
283 | }),
284 | );
285 | }
286 | }
287 |
288 | if (arrayItemValidator.isObjectData) {
289 | // Array > Object
290 | seaNodes = seaNodes.concat(
291 | traverse({
292 | traverseTarget: arrayItem as object,
293 | depth: _nextDepth,
294 | arrayIndexForObject: arrayIndex,
295 | sourceSet: {
296 | source: _source,
297 | sourceHandle: _sourceHandle,
298 | },
299 | parentNodePathIds: _parentNodePathIds,
300 | }),
301 | );
302 | addDefaultEdge({
303 | source: _source,
304 | target,
305 | sourceHandle: _sourceHandle,
306 | });
307 | } else if (arrayItemValidator.isArrayData) {
308 | // Array > Array
309 | const items: any[] = arrayItem as any[];
310 |
311 | seaNodes = seaNodes.concat(
312 | convertArrayToNode({
313 | nodeId: nextNodeId,
314 | depth: _nextDepth,
315 | arrayIndex,
316 | items,
317 | parentNodePathIds: _parentNodePathIds,
318 | isRootNode: false,
319 | }),
320 | );
321 | addDefaultEdge({
322 | source: _source,
323 | target,
324 | sourceHandle: _sourceHandle,
325 | });
326 |
327 | const isEmptyArray: boolean = items.length === 0;
328 |
329 | if (!isEmptyArray) {
330 | seaNodes = seaNodes.concat(
331 | traverse({
332 | traverseTarget: items,
333 | depth: _nextDepth,
334 | arrayIndexForObject: null,
335 | sourceSet: {
336 | source: _source,
337 | sourceHandle: _sourceHandle,
338 | },
339 | parentNodePathIds: _parentNodePathIds,
340 | }),
341 | );
342 | }
343 | } else if (arrayItemValidator.isPrimitiveData) {
344 | // Array > Primitive
345 | seaNodes = seaNodes.concat(
346 | convertPrimitiveToNode({
347 | nodeId: nextNodeId,
348 | depth: _nextDepth,
349 | arrayIndex,
350 | value: arrayItem as string | number | boolean | null,
351 | parentNodePathIds: _parentNodePathIds,
352 | }),
353 | );
354 | addDefaultEdge({
355 | source: _source,
356 | target,
357 | sourceHandle: _sourceHandle,
358 | });
359 | }
360 | });
361 | };
362 |
363 | const currentNodeId: string = formatNodeId(nodeSequence);
364 | const source: string = currentNodeId;
365 | const nextDepth: number = depth + 1;
366 | const nextParentNodePathIds: string[] = parentNodePathIds.concat([currentNodeId]);
367 | const isRootNode: boolean = sourceSet.source === undefined;
368 |
369 | if (isObject(traverseTarget)) {
370 | seaNodes = seaNodes.concat(
371 | convertObjectToNode({
372 | nodeId: currentNodeId,
373 | depth,
374 | obj: traverseTarget,
375 | parentNodePathIds,
376 | arrayIndexForObject,
377 | isRootNode,
378 | }),
379 | );
380 |
381 | Object.entries(traverseTarget).forEach(([propertyK, propertyV]) => {
382 | const sourceHandle: string = propertyK;
383 |
384 | if (isObject(propertyV)) {
385 | traverseObject({
386 | _obj: propertyV,
387 | _nextDepth: nextDepth,
388 | _parentNodePathIds: nextParentNodePathIds,
389 | _source: source,
390 | _sourceHandle: sourceHandle,
391 | });
392 | } else if (isArray(propertyV)) {
393 | traverseArray({
394 | _array: propertyV,
395 | _nextDepth: nextDepth,
396 | _parentNodePathIds: nextParentNodePathIds,
397 | _source: source,
398 | _sourceHandle: sourceHandle,
399 | });
400 | }
401 | });
402 | } else if (isArray(traverseTarget)) {
403 | /**
404 | * Unlike 'object' JSON code, 'array' JSON code needs to add an extra node if root node.
405 | */
406 | if (isRootNode) {
407 | seaNodes = seaNodes.concat(
408 | convertArrayToNode({
409 | nodeId: currentNodeId,
410 | depth,
411 | arrayIndex: ARRAY_ROOT_NODE_INDEX,
412 | items: traverseTarget,
413 | parentNodePathIds: ROOT_PARENT_NODE_PATH_IDS,
414 | isRootNode,
415 | }),
416 | );
417 | }
418 |
419 | traverseArray({
420 | _array: traverseTarget,
421 | _nextDepth: nextDepth,
422 | _parentNodePathIds: nextParentNodePathIds,
423 | _source: source,
424 | _sourceHandle: undefined,
425 | });
426 | }
427 |
428 | return seaNodes;
429 | };
430 |
431 | return {
432 | seaNodes: traverse({
433 | traverseTarget: json,
434 | depth: ROOT_NODE_DEPTH,
435 | parentNodePathIds: ROOT_PARENT_NODE_PATH_IDS,
436 | arrayIndexForObject: null,
437 | sourceSet: {},
438 | }),
439 | edges: [...defaultEdges, ...chainEdges],
440 | };
441 | };
442 |
--------------------------------------------------------------------------------
/src/store/json-engine/helpers/sea-node-position.helper.ts:
--------------------------------------------------------------------------------
1 | import dagre from 'dagre';
2 | import { Edge, XYPosition } from 'reactflow';
3 | import { sizes } from '../../../ui/constants/sizes.constant';
4 | import { SeaNode } from '../types/sea-node.type';
5 | import { isArraySeaNode, isObjectSeaNode, isPrimitiveSeaNode } from './sea-node.helper';
6 |
7 | export const getXYPosition = (depth: number): XYPosition => {
8 | const x: number = depth * sizes.nodeMaxWidth + depth * sizes.nodeGap;
9 | const y: number = 0; // y will be calculated in `getLayoutedSeaNodes` function with `dagre` library later.
10 |
11 | return { x, y } as XYPosition;
12 | };
13 |
14 | const calculateSeaNodeHeight = (seaNode: SeaNode): number => {
15 | if (isArraySeaNode(seaNode)) {
16 | return sizes.arrayNodeSize;
17 | }
18 |
19 | const NODE_TOP_BOTTOM_PADDING: number = sizes.nodePadding * 2;
20 |
21 | if (isObjectSeaNode(seaNode)) {
22 | return NODE_TOP_BOTTOM_PADDING + sizes.nodeContentHeight * Object.keys(seaNode.data.obj).length;
23 | }
24 |
25 | if (isPrimitiveSeaNode(seaNode)) {
26 | return NODE_TOP_BOTTOM_PADDING + sizes.nodeContentHeight * 1;
27 | }
28 |
29 | return 0;
30 | };
31 |
32 | /**
33 | * @reference https://reactflow.dev/docs/examples/layout/dagre/
34 | */
35 | export const getLayoutedSeaNodes = (seaNodes: SeaNode[], edges: Edge[]): SeaNode[] => {
36 | const dagreGraph = new dagre.graphlib.Graph();
37 |
38 | dagreGraph.setDefaultEdgeLabel(() => ({}));
39 | dagreGraph.setGraph({ rankdir: 'LR' }); // 'LR' is Left to Right direction.
40 |
41 | seaNodes.forEach((node: SeaNode) => {
42 | dagreGraph.setNode(node.id, { width: sizes.nodeMaxWidth, height: calculateSeaNodeHeight(node) });
43 | });
44 |
45 | edges
46 | .filter(({ type }) => type === 'default') // Do not consider 'chain' edge.
47 | .forEach((edge) => {
48 | dagreGraph.setEdge(edge.source, edge.target);
49 | });
50 |
51 | dagre.layout(dagreGraph);
52 |
53 | return seaNodes.map((node: SeaNode) => {
54 | const nodeWithPosition = dagreGraph.node(node.id);
55 | const nodeHeight: number = calculateSeaNodeHeight(node);
56 |
57 | return {
58 | ...node,
59 | // 'x' is already set at this moment because of `getXYPosition` function.
60 | position: {
61 | ...node.position,
62 | y: nodeWithPosition.y - nodeHeight / 2,
63 | },
64 | };
65 | });
66 | };
67 |
--------------------------------------------------------------------------------
/src/store/json-engine/helpers/sea-node.helper.ts:
--------------------------------------------------------------------------------
1 | import { NodeType } from '../enums/node-type.enum';
2 | import { ArraySeaNode, ObjectSeaNode, PrimitiveSeaNode, SeaNode } from '../types/sea-node.type';
3 |
4 | export const isObjectSeaNode = (node: SeaNode): node is ObjectSeaNode => {
5 | return node.type === NodeType.Object;
6 | };
7 |
8 | export const isArraySeaNode = (node: SeaNode): node is ArraySeaNode => {
9 | return node.type === NodeType.Array;
10 | };
11 |
12 | export const isPrimitiveSeaNode = (node: SeaNode): node is PrimitiveSeaNode => {
13 | return node.type === NodeType.Primitive;
14 | };
15 |
--------------------------------------------------------------------------------
/src/store/json-engine/json-engine.constant.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_STRINGIFIED_JSON: string = `{
2 | "app_name": "JSON Sea",
3 | "lat": 37.566789,
4 | "lng": 126.97842,
5 | "location": "Seoul, South Korea",
6 | "created_year": 2023,
7 | "app_release_date": "${new Date(2023, 0, 16).toISOString()}",
8 | "json_sea_access_date": "${new Date().toISOString()}",
9 | "active": true,
10 | "primary_color": "#0072F5",
11 | "null_property": null,
12 | "app_url": "https://jsonsea.com/",
13 | "sea_img_url": "https://raw.githubusercontent.com/altenull/json-sea/main/public/sea-image.jpeg",
14 | "sea_video_url": "https://raw.githubusercontent.com/altenull/json-sea/main/public/sea-video.mp4",
15 | "sea_audio_url": "https://raw.githubusercontent.com/altenull/json-sea/main/public/sea-audio.mp3",
16 | "thanks_to": [
17 | {
18 | "name": "NextUI",
19 | "url": "https://nextui.org/"
20 | },
21 | {
22 | "name": "React Flow",
23 | "url": "https://reactflow.dev/"
24 | }
25 | ],
26 | "complex_object": {
27 | "long_number_1": 234828153200,
28 | "long_number_2": 2123451234.2214,
29 | "object": {
30 | "boolean_1": true,
31 | "boolean_2": false,
32 | "array": [
33 | "J",
34 | "S",
35 | "O",
36 | "N",
37 | "S",
38 | "E",
39 | "A",
40 | [
41 | 2,
42 | 0,
43 | 2,
44 | 3
45 | ]
46 | ]
47 | }
48 | },
49 | "random_virtual_members": [
50 | {
51 | "id": 1,
52 | "first_name": "Torrin",
53 | "last_name": "Chaimson",
54 | "preferred_color": "#315e33",
55 | "birth": "2022-10-23T00:30:51Z",
56 | "random_url": "https://wikimedia.org/justo.aspx?nisi=vivamus&volutpat=in&eleifend=felis",
57 | "random_img_url": "https://dummyimage.com/136x207.png"
58 | },
59 | {
60 | "id": 2,
61 | "first_name": "Iggy",
62 | "last_name": "Dymick",
63 | "preferred_color": "#aef195",
64 | "birth": "2022-07-13T13:03:36Z",
65 | "random_url": "https://etsy.com/cum/sociis/natoque/penatibus/et/magnis.html?dui=vel",
66 | "random_img_url": "https://dummyimage.com/109x260.png"
67 | },
68 | {
69 | "id": 3,
70 | "first_name": "Lowell",
71 | "last_name": "Orsman",
72 | "preferred_color": "#f7a2e2",
73 | "birth": "2022-12-13T18:06:06Z",
74 | "random_url": "https://cbc.ca/nunc/commodo/placerat/praesent/blandit/nam.aspx?ipsum=felis&aliquam=ut",
75 | "random_img_url": "https://dummyimage.com/127x233.png"
76 | }
77 | ]
78 | }`;
79 |
--------------------------------------------------------------------------------
/src/store/json-engine/json-engine.store.ts:
--------------------------------------------------------------------------------
1 | import { Edge } from 'reactflow';
2 | import { create } from 'zustand';
3 | import { Entities } from '../../utils/array.util';
4 | import { isValidJson } from '../../utils/json.util';
5 | import { convertJsonTree } from './helpers/json-engine.helper';
6 | import { DEFAULT_STRINGIFIED_JSON } from './json-engine.constant';
7 | import { SeaNode } from './types/sea-node.type';
8 |
9 | export type JsonTree = {
10 | seaNodes: SeaNode[];
11 | seaNodeEntities: Entities;
12 | edges: Edge[];
13 | };
14 |
15 | type State = {
16 | stringifiedJson: string;
17 | isValidJson: boolean;
18 | latestValidStringifiedJson: string;
19 | jsonTree: JsonTree;
20 | };
21 |
22 | type Actions = {
23 | setStringifiedJson: (json: string) => void;
24 | resetJsonEngineStore: () => void;
25 | };
26 |
27 | const initialState: State = {
28 | stringifiedJson: DEFAULT_STRINGIFIED_JSON,
29 | isValidJson: isValidJson(DEFAULT_STRINGIFIED_JSON),
30 | latestValidStringifiedJson: DEFAULT_STRINGIFIED_JSON,
31 | jsonTree: convertJsonTree(JSON.parse(DEFAULT_STRINGIFIED_JSON)),
32 | };
33 |
34 | export const useJsonEngineStore = create((set) => ({
35 | ...initialState,
36 | setStringifiedJson: (stringifiedJson: string) => {
37 | set(() =>
38 | isValidJson(stringifiedJson)
39 | ? {
40 | stringifiedJson,
41 | isValidJson: true,
42 | latestValidStringifiedJson: stringifiedJson,
43 | jsonTree: convertJsonTree(JSON.parse(stringifiedJson)),
44 | }
45 | : {
46 | stringifiedJson,
47 | isValidJson: false,
48 | },
49 | );
50 | },
51 | resetJsonEngineStore: () => set(initialState),
52 | }));
53 |
--------------------------------------------------------------------------------
/src/store/json-engine/types/sea-node.type.ts:
--------------------------------------------------------------------------------
1 | import { Node } from 'reactflow';
2 | import { JsonDataType } from '../enums/json-data-type.enum';
3 | import { NodeType } from '../enums/node-type.enum';
4 |
5 | type SharedNodeData = {
6 | depth: number; // The depth starts from 0. (depth of root node is 0)
7 | stringifiedJson: string;
8 | parentNodePathIds: string[]; // e.g. [], ['n0'], ['n0', 'n3', 'n5'], ...
9 | };
10 |
11 | export type ObjectNodeData = SharedNodeData & {
12 | dataType: JsonDataType.Object;
13 | /**
14 | * Will be set if parent of `ObjectNode` is an array, so nullable.
15 | */
16 | arrayIndexForObject: number | null;
17 | obj: object;
18 | isRootNode: boolean;
19 | };
20 |
21 | export type ArrayNodeData = SharedNodeData & {
22 | dataType: JsonDataType.Array;
23 | arrayIndex: number;
24 | items: any[];
25 | isRootNode: boolean;
26 | };
27 |
28 | export type PrimitiveNodeData = SharedNodeData & {
29 | dataType: JsonDataType.String | JsonDataType.Number | JsonDataType.Boolean | JsonDataType.Null;
30 | /**
31 | * `PrimitiveNode` is always an item of specific array.
32 | * It means that the parent is always an `ArrayNode`.
33 | */
34 | arrayIndex: number;
35 | value: string | number | boolean | null;
36 | };
37 |
38 | export type ObjectSeaNode = Node;
39 | export type ArraySeaNode = Node;
40 | export type PrimitiveSeaNode = Node;
41 |
42 | export type SeaNode = ObjectSeaNode | ArraySeaNode | PrimitiveSeaNode;
43 |
--------------------------------------------------------------------------------
/src/store/landing/landing.store.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * [2023-02-02]
3 | * According to the NextUI official guide, the NextUI currently only works in client-side components.
4 | * It means that `Server Components` feature introduced from Next.js 13 isn't available.
5 | * @see https://nextui.org/docs/guide/nextui-plus-nextjs#next.js-13
6 | *
7 | * Due to above, the CLS(Cumulative Layout Shift) is seriously poor..
8 | * So I decided to show loading spinner until app UI is stable.
9 | * @HACK Determine whether to initialize app with `onInit` event of `ReactFlow` component.
10 | */
11 |
12 | import { create } from 'zustand';
13 |
14 | type State = {
15 | isAppInitalized: boolean;
16 | };
17 |
18 | type Actions = {
19 | initApp: () => void;
20 | resetLandingStore: () => void;
21 | };
22 |
23 | const initialState: State = {
24 | isAppInitalized: false,
25 | };
26 |
27 | export const useLandingStore = create((set) => ({
28 | ...initialState,
29 | initApp: () => set(() => ({ isAppInitalized: true })),
30 | resetLandingStore: () => set(initialState),
31 | }));
32 |
--------------------------------------------------------------------------------
/src/store/node-detail-view/hooks/useHoverNodeDetails.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useHover } from '../../../utils/react-hooks/useHover';
3 | import { HoveredNodeDetail, useNodeDetailViewStore } from '../node-detail-view.store';
4 |
5 | export const useHoverNodeDetails = (hoveredNodeDetails: HoveredNodeDetail[]) => {
6 | const [setHoveredNodeDetails, resetHoveredNodeDetails] = useNodeDetailViewStore((state) => [
7 | state.setHoveredNodeDetails,
8 | state.resetHoveredNodeDetails,
9 | ]);
10 |
11 | const [cardRef, isCardHovered] = useHover();
12 |
13 | useEffect(() => {
14 | isCardHovered ? setHoveredNodeDetails(hoveredNodeDetails) : resetHoveredNodeDetails();
15 | }, [isCardHovered]);
16 |
17 | return { cardRef };
18 | };
19 |
--------------------------------------------------------------------------------
/src/store/node-detail-view/node-detail-view.store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | export type HoveredNodeDetail = {
4 | nodeId: string;
5 | propertyK?: string; // Will be set when a `PropertyCard` component is hovered. (`PropertyCard` is used for Object)
6 | };
7 |
8 | type State = {
9 | hoveredNodeDetails: HoveredNodeDetail[];
10 | };
11 |
12 | type Actions = {
13 | setHoveredNodeDetails: (hoveredNodeDetails: HoveredNodeDetail[]) => void;
14 | resetHoveredNodeDetails: () => void;
15 | resetNodeDetailViewStore: () => void;
16 | };
17 |
18 | const initialState: State = {
19 | hoveredNodeDetails: [],
20 | };
21 |
22 | export const useNodeDetailViewStore = create((set) => ({
23 | ...initialState,
24 | setHoveredNodeDetails: (hoveredNodeDetails: HoveredNodeDetail[]) => set(() => ({ hoveredNodeDetails })),
25 | resetHoveredNodeDetails: () => set(() => ({ hoveredNodeDetails: initialState.hoveredNodeDetails })),
26 | resetNodeDetailViewStore: () => set(initialState),
27 | }));
28 |
--------------------------------------------------------------------------------
/src/store/settings/settings.store.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { localStorageService } from '../../services/local-storage.service';
3 |
4 | type State = {
5 | isMinimapOn: boolean;
6 | isNodePathOn: boolean;
7 | };
8 |
9 | type Actions = {
10 | toggleMinimap: () => void;
11 | toggleNodePath: () => void;
12 | resetSettingsStore: () => void;
13 | };
14 |
15 | const initialState: State = {
16 | isMinimapOn: localStorageService.getItem('settings:minimap'),
17 | isNodePathOn: localStorageService.getItem('settings:nodePath'),
18 | };
19 |
20 | export const useSettingsStore = create((set, get) => ({
21 | ...initialState,
22 | toggleMinimap: () => {
23 | const prev = get().isMinimapOn;
24 |
25 | set(() => ({
26 | isMinimapOn: !prev,
27 | }));
28 | localStorageService.setItem('settings:minimap', !prev);
29 | },
30 | toggleNodePath: () => {
31 | const prev = get().isNodePathOn;
32 |
33 | set(() => ({
34 | isNodePathOn: !prev,
35 | }));
36 | localStorageService.setItem('settings:nodePath', !prev);
37 | },
38 | resetSettingsStore: () => set(initialState),
39 | }));
40 |
--------------------------------------------------------------------------------
/src/ui/components/BooleanChip.tsx:
--------------------------------------------------------------------------------
1 | import { Chip } from '@nextui-org/chip';
2 | import { memo } from 'react';
3 |
4 | type Props = {
5 | value: boolean;
6 | size?: any;
7 | className?: any;
8 | };
9 |
10 | const _BooleanChip = ({ value, size = 'md', className }: Props) => {
11 | return (
12 |
13 | {JSON.stringify(value)}
14 |
15 | );
16 | };
17 |
18 | export const BooleanChip = memo(_BooleanChip);
19 |
--------------------------------------------------------------------------------
/src/ui/components/CircleTransparentButton.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { isFunction } from '../../utils/function.util';
3 |
4 | type Props = {
5 | children: React.ReactNode;
6 | className?: string;
7 | style?: React.CSSProperties;
8 | onClick?: React.MouseEventHandler;
9 | };
10 |
11 | const _CircleTransparentButton = ({ children, className, style, onClick }: Props) => {
12 | return (
13 |
22 | );
23 | };
24 |
25 | export const CircleTransparentButton = memo(_CircleTransparentButton);
26 |
--------------------------------------------------------------------------------
/src/ui/components/NullChip.tsx:
--------------------------------------------------------------------------------
1 | import { Chip } from '@nextui-org/chip';
2 | import { memo } from 'react';
3 |
4 | type Props = {
5 | size?: any;
6 | className?: any;
7 | };
8 |
9 | const _NullChip = ({ size = 'md', className }: Props) => {
10 | return (
11 |
12 | {JSON.stringify(null)}
13 |
14 | );
15 | };
16 |
17 | export const NullChip = memo(_NullChip);
18 |
--------------------------------------------------------------------------------
/src/ui/components/Text.tsx:
--------------------------------------------------------------------------------
1 | import React, { HTMLAttributes, memo, useCallback } from 'react';
2 | import { isString } from '../../utils/json.util';
3 |
4 | type TextElement = HTMLParagraphElement | HTMLHeadingElement;
5 |
6 | type Props = HTMLAttributes & {
7 | h1?: boolean;
8 | h2?: boolean;
9 | h3?: boolean;
10 | h4?: boolean;
11 | h5?: boolean;
12 | h6?: boolean;
13 | children: React.ReactNode;
14 | };
15 |
16 | const _Text = ({
17 | h1 = false,
18 | h2 = false,
19 | h3 = false,
20 | h4 = false,
21 | h5 = false,
22 | h6 = false,
23 | children,
24 | className,
25 | ...props
26 | }: Props) => {
27 | const extendClassName = useCallback(
28 | (base: string) => (isString(className) ? `${base} ${className}` : base),
29 | [className],
30 | );
31 |
32 | if (h1) {
33 | return (
34 |
38 | {children}
39 |
40 | );
41 | } else if (h2) {
42 | return (
43 |
44 | {children}
45 |
46 | );
47 | } else if (h3) {
48 | return (
49 |
50 | {children}
51 |
52 | );
53 | } else if (h4) {
54 | return (
55 |
56 | {children}
57 |
58 | );
59 | } else if (h5) {
60 | return (
61 |
62 | {children}
63 |
64 | );
65 | } else if (h6) {
66 | return (
67 |
68 | {children}
69 |
70 | );
71 | } else {
72 | return (
73 |
74 | {children}
75 |
76 | );
77 | }
78 | };
79 |
80 | /**
81 | * The NextUI had `` component in v1, but it disappeared in v2.]
82 | * So I added `` UI component for temporary use until the actual `` component returns.
83 | * @see https://github.com/nextui-org/nextui/issues/1767
84 | */
85 | export const Text = memo(_Text);
86 |
--------------------------------------------------------------------------------
/src/ui/constants/sizes.constant.ts:
--------------------------------------------------------------------------------
1 | export const sizes = {
2 | jsonSeaRecommendedWidth: 1024,
3 |
4 | // Node
5 | nodeMinWidth: 220, // Excepted array node.
6 | nodeMaxWidth: 440,
7 | arrayNodeSize: 64,
8 | nodeGap: 100,
9 | nodeContentHeight: 40,
10 | nodePadding: 12,
11 |
12 | // Node Detail Panel
13 | nodeDetailPanelWidth: 420,
14 | };
15 |
--------------------------------------------------------------------------------
/src/ui/hooks/useJsonSeaRecommendedWidth.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useRef, useState } from 'react';
4 | import { sizes } from '../constants/sizes.constant';
5 |
6 | export const useJsonSeaRecommendedWidth = () => {
7 | const isClient: boolean = typeof window === 'object';
8 | const lastWidth = useRef(null);
9 |
10 | const [isJsonSeaRecommendedWidth, setIsJsonSeaRecommendedWidth] = useState(
11 | isClient ? window.innerWidth >= sizes.jsonSeaRecommendedWidth : false
12 | );
13 |
14 | useEffect(() => {
15 | const handleResize = () => {
16 | if (window?.innerWidth !== lastWidth.current) {
17 | lastWidth.current = window.innerWidth;
18 |
19 | setIsJsonSeaRecommendedWidth(window.innerWidth >= sizes.jsonSeaRecommendedWidth);
20 | }
21 | };
22 |
23 | window?.addEventListener('resize', handleResize);
24 | return () => window?.removeEventListener('resize', handleResize);
25 | }, []);
26 |
27 | return { isJsonSeaRecommendedWidth } as const;
28 | };
29 |
--------------------------------------------------------------------------------
/src/ui/icon/Icon.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { isFunction } from '../../utils/function.util';
3 | import { iconNameToPathMap } from './icon.helper';
4 | import { IconName } from './icon.type';
5 |
6 | type Props = {
7 | icon: IconName;
8 | size: 24 | 32 | 40;
9 |
10 | /**
11 | * The icon color.
12 | * @default '#000000'
13 | */
14 | color?: string;
15 | style?: React.CSSProperties;
16 | onClick?: React.MouseEventHandler;
17 | };
18 |
19 | const _Icon = ({ icon, size, color = '#000000', style, onClick }: Props) => {
20 | return (
21 |
28 |
39 |
40 | );
41 | };
42 |
43 | export const Icon = memo(_Icon);
44 |
--------------------------------------------------------------------------------
/src/ui/icon/icon.type.ts:
--------------------------------------------------------------------------------
1 | import { tuple } from '../../utils/function.util';
2 |
3 | export const iconNames = tuple(
4 | 'file-plus',
5 | 'file-block',
6 | 'file-check',
7 | 'left-arrow-with-bar',
8 | 'right-arrow-with-bar',
9 | 'download',
10 | 'cloud-upload',
11 | 'camera',
12 | 'sun',
13 | 'moon',
14 | 'heart',
15 | 'github',
16 | 'array',
17 | 'object',
18 | 'array-empty',
19 | 'object-empty',
20 | 'settings'
21 | );
22 |
23 | export type IconName = typeof iconNames[number];
24 |
--------------------------------------------------------------------------------
/src/utils/array.util.ts:
--------------------------------------------------------------------------------
1 | export type Entities = {
2 | [id: string]: T;
3 | };
4 |
5 | export const arrayToEntities = (array: T[], id: string): Entities => {
6 | return array.reduce((acc: Entities, item: T) => {
7 | return (item as any)[id] === undefined
8 | ? acc
9 | : {
10 | ...acc,
11 | [(item as any)[id]]: item,
12 | };
13 | }, {});
14 | };
15 |
16 | export const isLastItemOfArray = (index: number, array: T): boolean => index === array.length - 1;
17 |
18 | export const isEmptyArray = (array: T): boolean => array.length < 1;
19 |
--------------------------------------------------------------------------------
/src/utils/file-download.util.ts:
--------------------------------------------------------------------------------
1 | export const downloadAsFile = (dataUrl: string, fileName: string) => {
2 | const anchor: HTMLAnchorElement = document.createElement('a');
3 |
4 | anchor.style.display = 'none';
5 | anchor.setAttribute('download', fileName);
6 | anchor.setAttribute('href', dataUrl);
7 |
8 | anchor.click();
9 | anchor.remove();
10 | };
11 |
--------------------------------------------------------------------------------
/src/utils/function.util.ts:
--------------------------------------------------------------------------------
1 | export const tuple = (...args: T) => args;
2 |
3 | export const noop = () => {};
4 |
5 | /**
6 | * Returns true if argument is a 'function' and false otherwise.
7 | * @param {unknown} v - the value to check
8 | */
9 | export const isFunction = (v: unknown): boolean => {
10 | return typeof v === 'function';
11 | };
12 |
--------------------------------------------------------------------------------
/src/utils/json.util.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns true if argument is an 'array' and false otherwise.
3 | * @param {unknown} v - the value to check
4 | */
5 | export const isArray = (v: unknown): v is T => {
6 | return Array.isArray(v);
7 | };
8 |
9 | /**
10 | * Returns true if argument is an 'object' and false otherwise.
11 | * Since the result of 'typeof []' is 'object', checks value with isArray() funciton.
12 | * And the result of 'typeof null' is 'object' too, validate v is not null.
13 | * @param {unknown} v - the value to check
14 | */
15 | export const isObject = (v: unknown): v is object => {
16 | return v !== null && !isArray(v) && typeof v === 'object';
17 | };
18 |
19 | /**
20 | * Returns true if argument is a 'string' and false otherwise.
21 | * @param {unknown} v - the value to check
22 | */
23 | export const isString = (v: unknown): v is string => {
24 | return typeof v === 'string';
25 | };
26 |
27 | /**
28 | * Returns true if argument is a 'number' and false otherwise.
29 | * @param {unknown} v - the value to check
30 | */
31 | export const isNumber = (v: unknown): v is number => {
32 | return typeof v === 'number';
33 | };
34 |
35 | /**
36 | * Returns true if argument is a 'boolean' and false otherwise.
37 | * @param {unknown} v - the value to check
38 | */
39 | export const isBoolean = (v: unknown): v is boolean => {
40 | return typeof v === 'boolean';
41 | };
42 |
43 | /**
44 | * Returns true if argument is a 'null' and false otherwise.
45 | * @param {unknown} v - the value to check
46 | */
47 | export const isNull = (v: unknown): v is null => {
48 | return v === null;
49 | };
50 |
51 | /**
52 | * Returns true if argument is a valid json code(string) and false otherwise.
53 | * @param {string} code - the value to check
54 | */
55 | export const isValidJson = (code: string): boolean => {
56 | try {
57 | const parsedCode = JSON.parse(code);
58 | return isObject(parsedCode) || isArray(parsedCode);
59 | } catch (error) {
60 | return false;
61 | }
62 | };
63 |
64 | export const formatJsonLikeData = (data: object | any[] | string): string => {
65 | const stringifyTarget = isString(data) ? JSON.parse(data) : data;
66 | const replacer: (number | string)[] | null = null;
67 | const space: string | number = 2;
68 |
69 | return JSON.stringify(stringifyTarget, replacer, space);
70 | };
71 |
--------------------------------------------------------------------------------
/src/utils/object.util.ts:
--------------------------------------------------------------------------------
1 | export const isEmptyObject = (obj: object): boolean => Object.keys(obj).length < 1;
2 |
--------------------------------------------------------------------------------
/src/utils/react-hooks/useBoolean.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useCallback, useState } from 'react';
4 |
5 | export const useBoolean = (initialBool?: boolean) => {
6 | const [bool, setBool] = useState(!!initialBool);
7 |
8 | const setTrue = useCallback(() => {
9 | setBool(true);
10 | }, []);
11 | const setFalse = useCallback(() => {
12 | setBool(false);
13 | }, []);
14 |
15 | const toggleBoolean = useCallback(() => {
16 | setBool((prevBool) => !prevBool);
17 | }, []);
18 |
19 | return {
20 | bool,
21 | setTrue,
22 | setFalse,
23 | toggleBoolean,
24 | } as const;
25 | };
26 |
--------------------------------------------------------------------------------
/src/utils/react-hooks/useCopyToClipboard.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 |
5 | type CopiedText = string | null;
6 | type CopyFn = (text: string) => Promise; // Return success
7 |
8 | export const useCopyToClipboard = () => {
9 | const [copiedText, setCopiedText] = useState(null);
10 |
11 | const copyToClipboard: CopyFn = async (text) => {
12 | if (!navigator?.clipboard) {
13 | console.warn('Clipboard not supported');
14 | return false;
15 | }
16 |
17 | // Try to save to clipboard then save it in the state if worked
18 | try {
19 | await navigator.clipboard.writeText(text);
20 | setCopiedText(text);
21 | return true;
22 | } catch (error) {
23 | console.warn('Copy failed', error);
24 | setCopiedText(null);
25 | return false;
26 | }
27 | };
28 |
29 | return { copiedText, copyToClipboard };
30 | };
31 |
--------------------------------------------------------------------------------
/src/utils/react-hooks/useCustomTheme.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useTheme } from 'next-themes';
4 |
5 | /**
6 | * There is type `UseThemeProps` for return type of `useTheme()` in `next-themes` but not exported.
7 | * So I define `UseThemeReturn` type here.
8 | */
9 | type UseThemeReturn = ReturnType;
10 |
11 | type UseCustomThemeReturn = UseThemeReturn & {
12 | theme: 'light' | 'dark';
13 | isDarkMode: boolean;
14 | };
15 |
16 | export const useCustomTheme = (): UseCustomThemeReturn => {
17 | const useThemeReturn = useTheme();
18 |
19 | const isDarkMode = useThemeReturn.theme === 'dark';
20 |
21 | return {
22 | ...useThemeReturn,
23 | theme: isDarkMode ? 'dark' : 'light',
24 | isDarkMode,
25 | };
26 | };
27 |
--------------------------------------------------------------------------------
/src/utils/react-hooks/useEnv.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 | import { env, featureFlag } from '../../environment';
5 |
6 | export const useEnv = () => {
7 | const [isLocalhost, setIsLocalhost] = useState(false);
8 |
9 | useEffect(() => {
10 | setIsLocalhost(window.location.hostname === env.localhost && featureFlag.debugMode);
11 | }, []);
12 |
13 | return { isLocalhost } as const;
14 | };
15 |
--------------------------------------------------------------------------------
/src/utils/react-hooks/useHover.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
4 |
5 | export const useHover = (): [MutableRefObject, boolean] => {
6 | const [isHovered, setIsHovered] = useState(false);
7 | const ref = useRef(null);
8 |
9 | const handleMouseOver = useCallback(() => setIsHovered(true), []);
10 | const handleMouseOut = useCallback(() => setIsHovered(false), []);
11 |
12 | useEffect(
13 | () => {
14 | const element = ref.current;
15 |
16 | element?.addEventListener('mouseover', handleMouseOver);
17 | element?.addEventListener('mouseout', handleMouseOut);
18 |
19 | return () => {
20 | element?.removeEventListener('mouseover', handleMouseOver);
21 | element?.removeEventListener('mouseout', handleMouseOut);
22 | };
23 | },
24 | [ref, handleMouseOver, handleMouseOut] // Recall only if ref changes
25 | );
26 |
27 | return [ref, isHovered];
28 | };
29 |
--------------------------------------------------------------------------------
/src/utils/react-hooks/useIntervallyForceUpdate.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 |
5 | export const useIntervallyForceUpdate = (ms: number) => {
6 | const [, setBoolean] = useState(false);
7 |
8 | useEffect(() => {
9 | const intervalId: NodeJS.Timeout = setInterval(() => {
10 | setBoolean((prev: boolean) => !prev);
11 | }, ms);
12 |
13 | return () => clearInterval(intervalId);
14 | }, [ms]);
15 | };
16 |
--------------------------------------------------------------------------------
/src/utils/react-hooks/useIsMounted.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 |
5 | export const useIsMounted = (): boolean => {
6 | const [isMounted, setIsMounted] = useState(false);
7 |
8 | useEffect(() => {
9 | setIsMounted(true);
10 |
11 | return () => {
12 | setIsMounted(false);
13 | };
14 | }, []);
15 |
16 | return isMounted;
17 | };
18 |
--------------------------------------------------------------------------------
/src/utils/react-hooks/useSimpleFetch.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useCallback, useState } from 'react';
4 |
5 | export const useSimpleFetch = () => {
6 | const [loading, setLoading] = useState(false);
7 | const [data, setData] = useState();
8 | const [error, setError] = useState(null);
9 |
10 | const fetchUrl = useCallback((url: string) => {
11 | setLoading(true);
12 | setError(null);
13 |
14 | fetch(url)
15 | .then((response) => response.json())
16 | .then(setData)
17 | .catch(setError)
18 | .finally(() => setLoading(false));
19 | }, []);
20 |
21 | const resetError = useCallback(() => {
22 | setError(null);
23 | }, []);
24 |
25 | return { loading, data, error, fetchUrl, resetError } as const;
26 | };
27 |
--------------------------------------------------------------------------------
/src/utils/react-hooks/useString.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useCallback, useState } from 'react';
4 |
5 | export const useString = (initialString?: string) => {
6 | const [string, setString] = useState(initialString ?? '');
7 |
8 | const clearString = useCallback(() => {
9 | setString('');
10 | }, []);
11 |
12 | return {
13 | string,
14 | isEmpty: string.length < 1,
15 | setString,
16 | clearString,
17 | } as const;
18 | };
19 |
--------------------------------------------------------------------------------
/src/utils/string.util.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {string} v - the value to double quote
3 | */
4 | export const encloseDoubleQuote = (v: string): string => {
5 | return `"${v}"`;
6 | };
7 |
8 | /**
9 | * @param {string | number} v - the value to enclose with square brackets
10 | */
11 | export const encloseSquareBrackets = (v: string | number): string => {
12 | return `[${String(v)}]`;
13 | };
14 |
15 | /**
16 | * Added to count `object properties` or `array items`.
17 | *
18 | * @example
19 | * '(empty)', '(1 item)', '(32 properties)', ...
20 | */
21 | export const formatCounting = (total: number, singular: string, plural: string): string => {
22 | if (total === 0) {
23 | return '(empty)';
24 | }
25 |
26 | return total === 1 ? `(1 ${singular})` : `(${total} ${plural})`;
27 | };
28 |
--------------------------------------------------------------------------------
/src/utils/window.util.ts:
--------------------------------------------------------------------------------
1 | export const openLinkAsNewTab = (url: string): void => {
2 | window?.open(url, '_blank', 'noopener,noreferrer');
3 | };
4 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const { nextui, commonColors } = require('@nextui-org/theme');
2 | const { sizes } = require('./src/ui/constants/sizes.constant');
3 |
4 | /** @type {import('tailwindcss').Config} */
5 | module.exports = {
6 | content: [
7 | './src/**/*.{js,ts,jsx,tsx,mdx}',
8 | './node_modules/@nextui-org/theme/dist/components/(button|chip|card|navbar|toggle|tooltip|modal|input|circular-progress|table|image|link).js',
9 | ],
10 | theme: {
11 | extend: {
12 | padding: {
13 | nodePadding: sizes.nodePadding,
14 | },
15 | height: {
16 | nodeContentHeight: sizes.nodeContentHeight,
17 | },
18 | minWidth: {
19 | arrayNodeSize: sizes.arrayNodeSize,
20 | nodeMinWidth: sizes.nodeMinWidth,
21 | nodeDetailPanelWidth: sizes.nodeDetailPanelWidth,
22 | },
23 | maxWidth: {
24 | arrayNodeSize: sizes.arrayNodeSize,
25 | nodeMaxWidth: sizes.nodeMaxWidth,
26 | nodeDetailPanelWidth: sizes.nodeDetailPanelWidth,
27 | },
28 | minHeight: {
29 | arrayNodeSize: sizes.arrayNodeSize,
30 | },
31 | maxHeight: {
32 | arrayNodeSize: sizes.arrayNodeSize,
33 | },
34 | },
35 | },
36 | darkMode: 'class',
37 | plugins: [
38 | nextui({
39 | addCommonColors: true, // override common colors (e.g. "blue", "green", "pink").
40 | defaultTheme: 'light', // default theme from the themes object
41 | defaultExtendTheme: 'light', // default theme to extend on custom themes
42 | layout: {}, // common layout tokens (applied to all themes)
43 | themes: {
44 | light: {
45 | layout: {},
46 | colors: {
47 | border: 'rgba(0, 0, 0, 0.15)',
48 | backgroundAlpha: 'rgba(255, 255, 255, 0.8)',
49 | backgroundContrast: '#ffffff',
50 | titleJson: '#00254D', // 'JSON' of 'JSON SEA'
51 | titleSea: '#4C76A5', // 'SEA' of 'JSON SEA'
52 | },
53 | },
54 | dark: {
55 | layout: {},
56 | colors: {
57 | border: 'rgba(255, 255, 255, 0.15)',
58 | backgroundAlpha: 'rgba(0, 0, 0, 0.6)',
59 | backgroundContrast: '#16181A',
60 | titleJson: commonColors.zinc[100], // 'JSON' of 'JSON SEA'
61 | titleSea: commonColors.zinc[100], // 'SEA' of 'JSON SEA'
62 | },
63 | },
64 | },
65 | }),
66 | ],
67 | };
68 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ]
22 | },
23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
24 | "exclude": ["node_modules"]
25 | }
26 |
--------------------------------------------------------------------------------