├── .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 | JSON Sea (light mode) 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 | 23 | {/* 'J' */} 24 | 28 | {/* 'S' */} 29 | 33 | {/* 'O' */} 34 | 38 | {/* 'N' */} 39 | 43 | {/* 'S' */} 44 | 48 | {/* 'E' */} 49 | 53 | {/* 'A' */} 54 | 58 | 59 | 70 | 74 | 75 | 76 | 80 | 84 | 88 | 92 | 97 | 98 | 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 | oceania 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 | 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 | image preview 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 | {title} 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 | 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 | 37 | 38 | 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 | --------------------------------------------------------------------------------