Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
**Overview**

This repo is a Next.js (v14) single-repo web app (frontend) for JSON Crack — an interactive JSON visualizer. The app is built with React, Next.js pages (in `src/pages/`), and client-side state using `zustand` stores under `src/store/` and `src/features/*/stores/`.

**Quick dev commands**

- `pnpm install` : install dependencies (project uses `pnpm`, `packageManager` is `[email protected]`).
- `pnpm dev` : local dev server (Next.js) on `http://localhost:3000`.
- `pnpm build` / `pnpm start` : build and start production Next app. `postbuild` runs `next-sitemap`.
- `pnpm lint` / `pnpm lint:fix` : TypeScript + ESLint + Prettier checks and autofix.
- Docker: `docker compose build` and `docker compose up` (README: app available at `http://localhost:8888` when using docker-compose).

**Important files & folders**

- `package.json` — scripts, Node engine (>=18.x), and `pnpm` requirement.
- `Dockerfile`, `docker-compose.yml` — local containerized workflows.
- `src/pages/*` — Next.js routes; editor UI is `src/pages/editor.tsx`.
- `src/features/*` — UI pieces (Editor toolbar, modals, specialized views). Example: `src/features/modals/ModalController.tsx` centralizes modal rendering.
- `src/store/*` — global client state (Zustand). Examples: `useConfig.ts`, `useFile.ts`, `useJson.ts`.
- `src/lib/utils/*` — conversion & generation helpers (`jsonAdapter.ts`, `generateType.ts`, `json2go.js`).

**Architecture & data flow (concise)**

- Editor content is managed via `src/store/useFile.ts`. When contents change, `useFile.setContents` converts/validates and triggers a debounced update to `useJson` and `useGraph`.
- Visual graph state and layout live in feature-scoped stores like `src/features/editor/views/GraphView/stores/useGraph`.
- UI components dynamically import large modules (e.g., `ModalController`, `TextEditor`, `LiveEditor`) to reduce SSR payloads — keep changes compatible with `next/dynamic` usage.
- Conversions and codegen are handled in `src/lib/utils` (e.g., `generateType.ts` uses `json_typegen_wasm` for TS/other languages and `json2go.js` for Go).

**Patterns & conventions (what the agent should follow)**

- State: prefer updating/reading from `zustand` stores (use `useXxx.getState()` only where the project uses it; stores frequently use `persist` middleware — do not rename persistence keys). Example: `useConfig` uses `persist(..., { name: "config" })`.
- Debounce & side-effects: `useFile` debounces updates and writes session data to `sessionStorage`. Preserve this behavior when moving logic.
- Dynamic imports: large components are loaded client-side (`ssr: false`) — ensure code runs safely in browser-only contexts (guard window/document usage).
- Styling: `styled-components` + Mantine themes. Theme toggling is controlled in `src/pages/editor.tsx` via `useConfig` and Mantine color scheme.
- Analytics & errors: `nextjs-google-analytics` events are used (see `useFile.setFile`) and Sentry config files exist in root (`sentry.*.config.ts`). Keep telemetry calls intact unless intentionally modifying analytics.

**Editing guidance / PR tips**

- Small, focused PRs are preferable: the app has tightly-coupled stores and UI components.
- When changing state shapes, update all consumers: search for `useConfig`, `useFile`, `useJson`, and `useGraph` usages.
- Avoid breaking server/client boundaries. Many modules are client-only (Monaco editor, window/sessionStorage). If you introduce server-side code inside dynamic client components, guard with `typeof window !== 'undefined'`.
- Keep build args and environment variables unchanged. Notable envs: `NEXT_PUBLIC_NODE_LIMIT` (node limit), `NEXT_PUBLIC_DISABLE_EXTERNAL_MODE` (controls external banner), and others referenced in `src/pages/editor.tsx` and features.

**Examples (copyable patterns)**

- Persisted store example (follow existing naming):

```ts
// src/store/useConfig.ts
persist(..., { name: "config" })
```

- Triggering a graph update via store (follow `useFile` pattern):

```ts
// setContents -> convert -> useGraph.getState().setLoading(true)
```

- Dev / prod commands to run locally:

```sh
pnpm install
pnpm dev
pnpm build
pnpm start
pnpm lint
docker compose up
```

**What the agent should NOT assume**

- There are no unit tests in the repo (no `jest` or test runners visible). Do not add test changes without a PR discussion.
- Do not change persisted store key names or the `sessionStorage` keys (`content`/`format`) unless coordinating with the maintainer.

**Where to look for more context**

- `src/pages/editor.tsx` — the main editor wiring (themes, QueryClient, dynamic imports).
- `src/store/useFile.ts` — how editor contents flow into JSON + graph updates.
- `src/lib/utils/*` — format conversion and type generation utilities.
- `README.md` — local docker and pnpm instructions.

If anything here is unclear or you want me to add examples for a specific subsystem (modals, conversions, or the graph layout stores), tell me which area and I will expand the instructions.
191 changes: 177 additions & 14 deletions src/features/modals/NodeModal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import React from "react";
import React, { useEffect, useState } from "react";
import type { ModalProps } from "@mantine/core";
import { Modal, Stack, Text, ScrollArea, Flex, CloseButton } from "@mantine/core";
import {
Modal,
Stack,
Text,
ScrollArea,
Flex,
CloseButton,
Button,
Textarea,
Group,
} from "@mantine/core";
import { CodeHighlight } from "@mantine/code-highlight";
import type { NodeData } from "../../../types/graph";
import useGraph from "../../editor/views/GraphView/stores/useGraph";
import useJson from "../../../store/useJson";
import useFile from "../../../store/useFile";
import { jsonToContent } from "../../../lib/utils/jsonAdapter";
import { toast } from "react-hot-toast";

// return object from json removing array and object fields
const normalizeNodeData = (nodeRows: NodeData["text"]) => {
Expand All @@ -28,25 +42,174 @@ const jsonPathToString = (path?: NodeData["path"]) => {

export const NodeModal = ({ opened, onClose }: ModalProps) => {
const nodeData = useGraph(state => state.selectedNode);
const [editing, setEditing] = useState(false);
const [editedText, setEditedText] = useState<string>(normalizeNodeData(nodeData?.text ?? []));
const [saving, setSaving] = useState(false);

useEffect(() => {
if (!editing) setEditedText(normalizeNodeData(nodeData?.text ?? []));
}, [nodeData, editing]);

const handleCancel = () => {
setEditing(false);
setEditedText(normalizeNodeData(nodeData?.text ?? []));
};

const handleSave = async () => {
if (saving) return;
setSaving(true);
try {
// parse current full JSON
const currentJsonStr = useJson.getState().json;
const doc = currentJsonStr ? JSON.parse(currentJsonStr) : {};

// determine if node is primitive
const rows = nodeData?.text ?? [];
const isPrimitive = rows.length === 1 && !rows[0].key;

let newValue: any;
try {
newValue = JSON.parse(editedText);
} catch (err) {
if (isPrimitive) {
newValue = editedText;
} else {
throw new Error("Invalid JSON for object/array nodes");
}
}

// helpers to get/set value at path
const getAtPath = (root: any, path: NodeData["path"] | undefined) => {
if (!path || path.length === 0) return root;
let cur = root;
for (let i = 0; i < path.length; i++) {
const key = path[i] as any;
if (cur == null) return undefined;
cur = typeof key === "number" ? cur[key] : cur[key];
}
return cur;
};

const setAtPath = (root: any, path: NodeData["path"] | undefined, value: any) => {
if (!path || path.length === 0) return value;
let cur = root;
for (let i = 0; i < path.length - 1; i++) {
const key = path[i] as any;
if (cur[key] == null) {
const nextKey = path[i + 1] as any;
cur[key] = typeof nextKey === "number" ? [] : {};
}
cur = typeof key === "number" ? cur[key] : cur[key];
}
const last = path[path.length - 1] as any;
if (typeof last === "number") cur[last] = value;
else cur[last] = value;
return root;
};

// decide merging strategy to preserve nested fields
const existingValue = getAtPath(doc, nodeData?.path);
let finalValue: any;

if (Array.isArray(existingValue)) {
finalValue = newValue;
} else if (
existingValue &&
typeof existingValue === "object" &&
!Array.isArray(existingValue) &&
newValue &&
typeof newValue === "object" &&
!Array.isArray(newValue)
) {
finalValue = { ...existingValue, ...newValue };
} else {
finalValue = newValue;
}

const updatedDoc = setAtPath(doc, nodeData?.path, finalValue);
const updatedJsonStr = JSON.stringify(updatedDoc, null, 2);

// update graph immediately
useJson.getState().setJson(updatedJsonStr);

// update left editor contents in current format
const format = useFile.getState().format;
try {
const converted = await jsonToContent(updatedJsonStr, format);
useFile.getState().setContents({ contents: converted, hasChanges: true, format });
} catch (convErr) {
console.warn("Failed to convert JSON to current format", convErr);
}

toast.success("Node updated");
setEditing(false);
onClose?.();
} catch (error: any) {
toast.error(error?.message ?? "Failed to save node");
} finally {
setSaving(false);
}
};

return (
<Modal size="auto" opened={opened} onClose={onClose} centered withCloseButton={false}>
<Stack pb="sm" gap="sm">
<Stack gap="xs">
<Flex justify="space-between" align="center">
<Text fz="xs" fw={500}>
Content
</Text>
<CloseButton onClick={onClose} />
<Flex direction="column">
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
<Text fz="lg" fw={400}>
Node Content
</Text>
<CloseButton onClick={onClose} />
</Flex>

<Flex justify="space-between" align="center">
<Text fz="xs" fw={500}>
Content
</Text>
<Group>
{editing ? (
<>
<Button
size="xs"
variant="filled"
color="green"
onClick={handleSave}
loading={saving}
>
Save
</Button>
<Button size="xs" variant="default" onClick={handleCancel}>
Cancel
</Button>
</>
) : (
<Button size="xs" variant="filled" color="blue" onClick={() => setEditing(true)}>
Edit
</Button>
)}
</Group>
</Flex>
</Flex>
<ScrollArea.Autosize mah={250} maw={600}>
<CodeHighlight
code={normalizeNodeData(nodeData?.text ?? [])}
miw={350}
maw={600}
language="json"
withCopyButton
/>
{!editing ? (
<CodeHighlight
code={normalizeNodeData(nodeData?.text ?? [])}
miw={350}
maw={600}
language="json"
withCopyButton
/>
) : (
<div style={{ minWidth: 350, maxWidth: 600 }}>
<Textarea
value={editedText}
onChange={e => setEditedText(e.currentTarget.value)}
autosize
minRows={6}
/>
</div>
)}
</ScrollArea.Autosize>
</Stack>
<Text fz="xs" fw={500}>
Expand Down