Compare commits
3 Commits
2635b45973
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 223336c606 | |||
| cbd8f1568b | |||
| 166c7b0b7f |
216
ARCHITECTURE.md
Normal file
216
ARCHITECTURE.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Architecture Overview
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Zui is a node-based visual editor for composing configs, templates, variables, and AI-generated content. The frontend is a React 18 SPA backed by a thin Express API for AI agent calls. All graph data is persisted in `localStorage`; the backend is stateless.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| --- | --- |
|
||||
| Frontend framework | React 18, TypeScript (strict), Vite 7 |
|
||||
| Graph canvas | `@xyflow/react` v12 (React Flow) |
|
||||
| State management | Zustand 5 (command/reducer pattern) |
|
||||
| Styling | Tailwind CSS + Shadcn UI (`@radix-ui/*`) |
|
||||
| Rich text | BlockNote 0.36 with custom blocks |
|
||||
| Templating | Nunjucks |
|
||||
| Graph traversal | `graphology` + `graphology-traversal` |
|
||||
| Routing | React Router v6 |
|
||||
| Backend | Node.js 20+, TypeScript, Express 4 |
|
||||
| AI SDK | Vercel AI SDK v4 + `@ai-sdk/openai` |
|
||||
| Testing | Vitest, React Testing Library |
|
||||
|
||||
---
|
||||
|
||||
## 3. Project Structure
|
||||
|
||||
```text
|
||||
frontend/src/
|
||||
├── main.tsx # Entry: registers node types & config types, mounts app
|
||||
├── app/
|
||||
│ ├── canvas/ # CanvasPage, canvasStore (Zustand), canvasStore.test.ts
|
||||
│ ├── kosmos/ # KosmosPage, sidebar, AI settings, KosmosContext
|
||||
│ └── recollections/
|
||||
│ ├── RecollectionsPage # Workspace list
|
||||
│ ├── RecollectionLayout # Shared layout (sidebar + outlet)
|
||||
│ ├── flux/ # Flux view (graph canvas)
|
||||
│ ├── logos/ # Logos view (BlockNote rich text)
|
||||
│ └── katalogos/ # Katalogos view (artifact gallery)
|
||||
├── components/
|
||||
│ ├── graph/ # AnimatedEdge, BaseNode, handles, keyboard shortcuts
|
||||
│ ├── nodes/ # One folder per node type
|
||||
│ └── ui/ # Shadcn primitives
|
||||
├── hooks/ # useGraphStateWithHistory, useResizeHeight, …
|
||||
└── lib/
|
||||
└── graph/
|
||||
├── nodeRegistry.ts # Mutable plugin registry
|
||||
├── registerBuiltinNodes.tsx
|
||||
├── nodeTypeBuilder.ts # Fluent builder for node descriptors
|
||||
├── configTypes.ts # Output type registry (plantuml, markdown, wireframe)
|
||||
├── rendering.ts # 3-step rendering pipeline contract
|
||||
├── state.ts # Graph state types
|
||||
└── canvasStore/ # Reducer + selectors
|
||||
|
||||
backend/src/
|
||||
├── index.ts # Express app, CORS, routes, error handler
|
||||
├── routes/agentRoutes.ts # POST /api/agent, POST /api/agent/stream
|
||||
├── services/agentService.ts # LLM call logic (Vercel AI SDK)
|
||||
├── repositories/cacheRepository.ts
|
||||
├── models/index.ts
|
||||
└── middleware/rateLimiter.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Key Architectural Patterns
|
||||
|
||||
### 4.1 Command / Reducer Store (canvasStore)
|
||||
|
||||
The Zustand canvas store uses a pure reducer (`canvasStoreReducer`) with a discriminated union of `CanvasCommand` types. All mutations go through `dispatch(command)`. In dev mode every command is logged to the console.
|
||||
|
||||
The store has three slices:
|
||||
|
||||
- **`graph`** — nodes and edges (the React Flow state)
|
||||
- **`path`** — trigger pulse for edge animation
|
||||
- **`ui`** — renaming state, fullscreen node, pending connection
|
||||
|
||||
### 4.2 Delta-based Undo / Redo
|
||||
|
||||
`useGraphStateWithHistory` computes *inverse deltas* (only the nodes/edges that changed) rather than snapshotting the full graph. Maximum 100 history entries. Drag operations commit a pre-drag snapshot on `onNodeDragStop`.
|
||||
|
||||
### 4.3 Node Plugin Registry
|
||||
|
||||
Node types are registered via a mutable registry in `nodeRegistry.ts`. The built-ins are registered at app init from `registerBuiltinNodes.tsx`. Adding a new node type requires:
|
||||
|
||||
1. Implement a React component for the node.
|
||||
2. Create a descriptor using `NodeTypeBuilder`.
|
||||
3. Call `registerNodeType(descriptor)` — no edits to core code.
|
||||
|
||||
### 4.4 Fluent Builder for Node Descriptors
|
||||
|
||||
`NodeTypeBuilder` provides a type-safe method-chaining API (`idPrefix`, `withInputOutput`, `classification`, `allowedSourceTypes`, `help`, `menu`, `withFullscreen`, `sourceRenderingLogic`, …). Required fields are validated at `.build()`.
|
||||
|
||||
### 4.5 Three-Step Rendering Pipeline
|
||||
|
||||
Defined as an explicit contract in `rendering.ts`:
|
||||
|
||||
1. **Resolve** — source node (Config/Agent) produces `{ resolved, outputTypeId, reasoning? }`
|
||||
2. **Render** — output type handler converts the resolved string (PlantUML → Kroki SVG, Markdown → HTML, Wireframe → SVG)
|
||||
3. **Display** — the Rendering node shows an image viewport or scrollable HTML
|
||||
|
||||
### 4.6 Node Classification System
|
||||
|
||||
Four metaphorical classes drive the create menu grouping:
|
||||
|
||||
| Class | Node type | Metaphor |
|
||||
| --- | --- | --- |
|
||||
| `psyche` | Variable | source / input |
|
||||
| `physis` | Data (CSV) | physical data |
|
||||
| `pneuma` | Config / Render | transformation |
|
||||
| `archon` | Agent | AI / LLM |
|
||||
|
||||
### 4.7 Recollections (Workspaces)
|
||||
|
||||
Each workspace (recollection) is stored in `localStorage`:
|
||||
|
||||
| Key | Content |
|
||||
| --- | --- |
|
||||
| `zui_platform_recollections` | Metadata index (id, name, icon, timestamps) |
|
||||
| `zui_graph_<id>` | Flux graph (nodes + edges) |
|
||||
| `zui_logos_<id>` | BlockNote rich text document |
|
||||
| `zui_render_cache_<id>` | Rendered artifact cache |
|
||||
|
||||
Legacy key prefix `emanations` is migrated to `recollections` on first load.
|
||||
|
||||
### 4.8 User-configurable AI Connection
|
||||
|
||||
`KosmosContext` persists `aiConnection` (provider, baseURL, model, apiKey) in `localStorage` under `zui_ai_connection`. The Agent node forwards this to the backend with each request, so users can switch between OpenAI and local LLMs (LM Studio, Ollama) from the UI without env variable changes.
|
||||
|
||||
### 4.9 Diagram Rendering via Kroki
|
||||
|
||||
PlantUML diagrams are rendered by proxying to `https://kroki.io` via `/api/kroki/plantuml/svg`. This proxy is configured in both the Vite dev server and Nginx production config. Timeout: 15 seconds.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Flow
|
||||
|
||||
```text
|
||||
User interaction
|
||||
→ dispatch(CanvasCommand) [canvasStore reducer]
|
||||
→ Zustand state update [graph / path / ui slices]
|
||||
→ React re-render [React Flow nodes/edges]
|
||||
|
||||
Rendering node triggered
|
||||
→ Resolve source node [Config/Agent → resolved string]
|
||||
→ Render output type [plantuml/markdown/wireframe]
|
||||
→ Kroki proxy (PlantUML) [GET /api/kroki/plantuml/svg]
|
||||
→ Agent backend (AI) [POST /api/agent or /api/agent/stream]
|
||||
→ Display artifact [image / HTML / SVG viewport]
|
||||
|
||||
Agent node (streaming)
|
||||
→ POST /api/agent/stream [Express → agentService]
|
||||
→ Vercel AI SDK streams SSE [backend → frontend]
|
||||
→ useStreamingContent hook consumes [updates node data]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Backend Architecture
|
||||
|
||||
The backend is intentionally thin and stateless. It has three responsibilities:
|
||||
|
||||
1. **Proxy AI calls** — forwards prompts to the configured LLM (OpenAI or OpenAI-compatible).
|
||||
2. **Stream responses** — `POST /api/agent/stream` uses `pipeTextStreamToResponse` from the Vercel AI SDK.
|
||||
3. **Health check** — `GET /health` for Docker/orchestration readiness probes.
|
||||
|
||||
The `InMemoryCache` and `rateLimiter` middleware are implemented but not yet wired into the agent routes.
|
||||
|
||||
---
|
||||
|
||||
## 7. Contribution Workflow
|
||||
|
||||
1. Fork the repository and clone locally.
|
||||
2. Install dependencies: `cd frontend && npm install` and `cd backend && npm install`.
|
||||
3. Run the dev servers (see README).
|
||||
4. Create a feature branch: `git checkout -b feat/your-feature`.
|
||||
5. Follow the Coding Standards (see [CONTRIBUTING.md](CONTRIBUTING.md)).
|
||||
6. Submit a Pull Request with a descriptive title, linked issue(s), updated tests, and documentation changes if relevant.
|
||||
|
||||
### PR Review Checklist
|
||||
|
||||
- [ ] Readability: clear naming, minimal nesting
|
||||
- [ ] TypeScript: no `any`, strict types throughout
|
||||
- [ ] Performance: no unnecessary re-renders, proper memoization
|
||||
- [ ] Accessibility: semantic HTML, ARIA attributes where needed
|
||||
- [ ] Security: no hardcoded secrets, input validated at boundaries
|
||||
|
||||
---
|
||||
|
||||
## 8. Coding Standards
|
||||
|
||||
- **TypeScript**: strict mode (`strict`, `noImplicitAny`, `noUnusedLocals`).
|
||||
- **Naming**: PascalCase for components, camelCase for functions/variables, UPPER_SNAKE_CASE for constants.
|
||||
- **File structure**: domain-grouped under `src/app/<domain>/` and `src/components/<category>/`.
|
||||
- **Formatting**: Prettier via `frontend/prettier.config.cjs`; line length ≤ 120.
|
||||
- **Comments**: JSDoc for public APIs; inline only where logic is non-obvious.
|
||||
|
||||
---
|
||||
|
||||
## 9. Quick Reference for AI Agents
|
||||
|
||||
| Topic | Location |
|
||||
| --- | --- |
|
||||
| Entry point | `frontend/src/main.tsx` |
|
||||
| Canvas store | `frontend/src/app/canvas/canvasStore.ts` |
|
||||
| Store reducer | `frontend/src/app/canvas/canvasStoreReducer.ts` |
|
||||
| Node registry | `frontend/src/lib/graph/nodeRegistry.ts` |
|
||||
| Node builder | `frontend/src/lib/graph/nodeTypeBuilder.ts` |
|
||||
| Rendering pipeline | `frontend/src/lib/graph/rendering.ts` |
|
||||
| Built-in node registration | `frontend/src/lib/graph/registerBuiltinNodes.tsx` |
|
||||
| AI settings context | `frontend/src/app/kosmos/KosmosContext.tsx` |
|
||||
| Backend entry | `backend/src/index.ts` |
|
||||
| Agent service | `backend/src/services/agentService.ts` |
|
||||
|
||||
*This document is a living reference; update it when architectural decisions change.*
|
||||
61
CONTRIBUTING.md
Normal file
61
CONTRIBUTING.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Contribution Guide
|
||||
|
||||
## 1. Coding Standards
|
||||
|
||||
- Use TypeScript with `strict` mode enabled; no `any` types.
|
||||
- Import order: core libs → third-party → project aliases (`@/`) → relative.
|
||||
- Naming: PascalCase for components, camelCase for functions/variables, UPPER_SNAKE_CASE for constants.
|
||||
- Add JSDoc comments for all public APIs and exported types.
|
||||
- Line length ≤ 120 characters; use Prettier for formatting (`frontend/prettier.config.cjs`).
|
||||
- Run `npm run lint` and fix all warnings before opening a PR.
|
||||
|
||||
## 2. Branch Workflow
|
||||
|
||||
- Create a feature branch: `git checkout -b feat/<short-description>`
|
||||
- Keep branches up to date with `main` via `git rebase`.
|
||||
- Submit a Pull Request with a clear title and description.
|
||||
- Ensure the PR passes CI (tests, lint, type-check).
|
||||
- Address review comments promptly.
|
||||
|
||||
## 3. Running Tests
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
cd frontend && npm install
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test
|
||||
|
||||
# Run tests once (CI)
|
||||
npm run test:run
|
||||
|
||||
# Type-check
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## 4. Making Your First Contribution
|
||||
|
||||
1. Pick a beginner-friendly issue labeled `good first issue`.
|
||||
2. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the relevant module.
|
||||
3. Make a small, focused change; avoid refactoring unrelated code.
|
||||
4. Run the relevant tests and confirm they pass.
|
||||
5. Commit and push, then open a PR with a clear description of what and why.
|
||||
|
||||
## 5. Where to Extend
|
||||
|
||||
| Goal | Where to look |
|
||||
| --- | --- |
|
||||
| Add a new node type | `src/lib/graph/nodeTypeBuilder.ts` + `registerBuiltinNodes.tsx` |
|
||||
| Add a new output/render type | `src/lib/graph/configTypes.ts` + `rendering.ts` |
|
||||
| Change canvas state shape | `src/app/canvas/canvasStore.ts` + reducer + tests |
|
||||
| Add a new Logos block | `src/app/recollections/logos/logosSchema.ts` |
|
||||
| Add a backend endpoint | `backend/src/routes/` → `services/` → `index.ts` |
|
||||
|
||||
## 6. Useful Links
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — architecture overview, key patterns, module map
|
||||
- [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md) — bottleneck analysis and improvement plan
|
||||
- [docs/CODE_REVIEW_CHECKLIST.md](docs/CODE_REVIEW_CHECKLIST.md) — PR review checklist
|
||||
- [docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md](docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md) — node plugin system design
|
||||
|
||||
*Thank you for contributing!*
|
||||
108
PERFORMANCE_IMPROVEMENTS.md
Normal file
108
PERFORMANCE_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Performance Improvements Plan
|
||||
|
||||
## 1. Current State
|
||||
|
||||
The core rendering and state management architecture is already well-structured for performance:
|
||||
|
||||
- **Command/reducer store** — all mutations go through a pure reducer; individual selectors can prevent unnecessary re-renders.
|
||||
- **Delta-based undo/redo** — only diffs are stored, not full graph snapshots (max 100 entries).
|
||||
- **Plugin node registry** — node types are loaded once at app init, not dynamically on each render.
|
||||
- **Streaming AI responses** — `POST /api/agent/stream` uses SSE so the UI updates incrementally.
|
||||
|
||||
The sections below identify remaining bottlenecks and concrete next steps.
|
||||
|
||||
---
|
||||
|
||||
## 2. Known Bottlenecks
|
||||
|
||||
| # | Issue | Location | Impact | Root Cause |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Unguarded re-renders on canvas | `CanvasPage.tsx` | Frame drops on large graphs | Components not subscribed to granular store slices |
|
||||
| 2 | Nunjucks template resolution on every render | `rendering.ts` resolve step | Slow Config node updates | No memoization of template output keyed to input hash |
|
||||
| 3 | Kroki SVG requests not deduplicated | `rendering.ts` render step | Redundant network calls | No in-flight request deduplication or client-side cache |
|
||||
| 4 | Sidebar tree renders all items | `KosmosPage` recollection tree | Scrolling lag with many workspaces | No list virtualization |
|
||||
| 5 | Backend cache not wired to agent routes | `agentRoutes.ts` | Repeated identical LLM calls | `InMemoryCache` and `rateLimiter` exist but are unused |
|
||||
| 6 | Full graph serialized to localStorage on every change | `useGraphStateWithHistory` | Storage I/O on every keypress | No debounce on the persistence write |
|
||||
| 7 | Initial bundle size | Vite build | Slow first load | Heavy deps (BlockNote, React Flow, Nunjucks) loaded eagerly |
|
||||
|
||||
---
|
||||
|
||||
## 3. Recommended Improvements
|
||||
|
||||
### 3.1 Granular Store Subscriptions
|
||||
|
||||
Zustand supports slice-level subscriptions. Node components should select only their own data slice:
|
||||
|
||||
```ts
|
||||
// Instead of subscribing to the entire graph:
|
||||
const node = useCanvasStore(s => s.graph.nodes.find(n => n.id === id))
|
||||
```
|
||||
|
||||
This prevents all nodes from re-rendering when a single node changes.
|
||||
|
||||
### 3.2 Memoize Template Resolution
|
||||
|
||||
Cache the Nunjucks resolution output keyed to a hash of the template source plus variable inputs. Invalidate only when those inputs change:
|
||||
|
||||
```ts
|
||||
const resolved = useMemo(
|
||||
() => resolveTemplate(template, variables),
|
||||
[templateHash, variableHash]
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 Deduplicate Kroki Requests
|
||||
|
||||
Add a simple in-flight map in the render step: if a request for the same PlantUML source is already pending, reuse its promise. Cache successful responses keyed to the source string with a short TTL (e.g. 5 minutes).
|
||||
|
||||
### 3.4 Wire Backend Cache and Rate Limiter
|
||||
|
||||
`InMemoryCache` and `rateLimiter` middleware are implemented in `backend/src/`. Connect them to `agentRoutes.ts`:
|
||||
|
||||
1. Add cache lookup before calling the AI service.
|
||||
2. Store the response on cache miss.
|
||||
3. Apply rate limiting per IP to prevent abuse.
|
||||
|
||||
### 3.5 Debounce localStorage Writes
|
||||
|
||||
Wrap the graph persistence call in a debounce (e.g., 300 ms) to avoid a write on every keystroke or node drag. The delta-based history already computes minimal diffs; the bottleneck is the frequency of writes.
|
||||
|
||||
### 3.6 Virtualize the Sidebar Tree
|
||||
|
||||
Integrate `react-arborist` (already installed) with virtualization enabled for the recollection sidebar when item count exceeds a threshold (~50).
|
||||
|
||||
### 3.7 Code Split Heavy Routes
|
||||
|
||||
Add lazy imports for the three heavy route components so the initial bundle only loads what the user navigates to:
|
||||
|
||||
```ts
|
||||
const FluxRoute = lazy(() => import('./app/recollections/flux/FluxRoute'))
|
||||
const LogosPage = lazy(() => import('./app/recollections/logos/LogosPage'))
|
||||
const KatalogosPage = lazy(() => import('./app/recollections/katalogos/KatalogosPage'))
|
||||
```
|
||||
|
||||
### 3.8 Enable Brotli Compression in Nginx
|
||||
|
||||
Add brotli/gzip compression to `frontend/nginx.conf` for JS, CSS, and SVG assets. This can cut transfer size by 60–70% for the JS bundle.
|
||||
|
||||
---
|
||||
|
||||
## 4. Success Metrics
|
||||
|
||||
| Metric | Current (estimated) | Target |
|
||||
| --- | --- | --- |
|
||||
| Frame time on 50-node canvas | ~16 ms | < 10 ms |
|
||||
| Initial JS bundle (gzipped) | ~800 KB | < 600 KB |
|
||||
| Repeated identical LLM calls | uncached | 0 network round-trips |
|
||||
| localStorage write frequency | every change | debounced 300 ms |
|
||||
|
||||
---
|
||||
|
||||
## 5. Contribution Path
|
||||
|
||||
1. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the module you're optimizing.
|
||||
2. Pick one item from section 2.
|
||||
3. Add a Vitest benchmark (`performance.now()` before/after) alongside your change.
|
||||
4. Submit a PR with the benchmark results in the description and update this file's "Current" column.
|
||||
|
||||
*This plan is a living document; update the metrics table when improvements land.*
|
||||
116
README.md
116
README.md
@@ -1,42 +1,61 @@
|
||||
# Zui
|
||||
|
||||
Node-based editor (React Flow) for configs, variables, and rendering (PlantUML, Markdown, etc.). Optional Node.js backend API for demos or future features (e.g. todos CRUD).
|
||||
Node-based visual editor (React Flow) for composing configs, variables, templates, and AI-generated content. Three views per workspace: **Flux** (graph canvas), **Logos** (rich text), and **Katalogos** (artifact gallery). Optional Node.js backend for AI agent calls.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
my-app/
|
||||
├── frontend/ # React app (Vite, TypeScript)
|
||||
│ ├── Dockerfile # Multi-stage for prod (build → Nginx)
|
||||
│ ├── Dockerfile.dev # Dev with hot reload
|
||||
```text
|
||||
zui/
|
||||
├── frontend/ # React SPA (Vite, TypeScript)
|
||||
│ ├── src/
|
||||
│ ├── public/
|
||||
│ │ ├── main.tsx # Entry point: registers node/config types, mounts React
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── canvas/ # Core graph editor (React Flow + Zustand)
|
||||
│ │ │ ├── kosmos/ # Platform shell: sidebar, recollection list, AI settings
|
||||
│ │ │ └── recollections/ # Logos (rich text), Flux (canvas), Katalogos (gallery)
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── graph/ # AnimatedEdge, BaseNode, handles, keyboard shortcuts
|
||||
│ │ │ ├── nodes/ # One folder per node type (agent, config, variable, …)
|
||||
│ │ │ └── ui/ # Shadcn-based primitives
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ └── lib/
|
||||
│ │ └── graph/ # Registry, types, state, rendering pipeline, Nunjucks utils
|
||||
│ ├── Dockerfile # Multi-stage: Vite build → Nginx
|
||||
│ ├── Dockerfile.dev # Dev with hot reload
|
||||
│ ├── nginx.conf
|
||||
│ └── package.json
|
||||
├── backend/ # Node.js/Express API
|
||||
│ ├── Dockerfile
|
||||
│ ├── src/
|
||||
│ │ └── index.js
|
||||
│ │ ├── index.ts # Express entry: registers routes, CORS, error handler
|
||||
│ │ ├── routes/agentRoutes.ts
|
||||
│ │ ├── services/agentService.ts
|
||||
│ │ ├── repositories/cacheRepository.ts
|
||||
│ │ ├── models/index.ts
|
||||
│ │ └── middleware/rateLimiter.ts
|
||||
│ └── package.json
|
||||
├── docs/
|
||||
│ ├── ARCHITECTURE.md
|
||||
│ ├── PERFORMANCE.md
|
||||
│ ├── NODE_TYPE_EXTENSIBILITY_PROPOSAL.md
|
||||
│ └── CODE_REVIEW_CHECKLIST.md
|
||||
├── docker-compose.yml
|
||||
├── .dockerignore
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
Ignored by git: `node_modules`, `dist`, `.env`, `.env.*` (see [.gitignore](.gitignore)). Local Docker overrides: `docker-compose.override.yml` (optional, not committed).
|
||||
Ignored by git: `node_modules`, `dist`, `.env`, `.env.*`. Local Docker overrides: `docker-compose.override.yml` (optional, not committed).
|
||||
|
||||
---
|
||||
|
||||
## Run locally (dev)
|
||||
|
||||
**Frontend only:**
|
||||
**Frontend only** (no AI agent):
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && npm run dev
|
||||
# → http://localhost:3000
|
||||
```
|
||||
|
||||
**Frontend + backend** (for AI agent and health):
|
||||
**Frontend + backend** (for AI agent and health check):
|
||||
|
||||
```bash
|
||||
# Terminal 1 – backend
|
||||
@@ -56,70 +75,79 @@ cd frontend && npm install && npm run dev
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
- **Frontend**: http://localhost:3000 (Nginx; `/api/*` proxied to backend).
|
||||
- **Backend**: http://localhost:8080 (Express).
|
||||
- **Frontend**: <http://localhost:3000> (Nginx; `/api/*` proxied to backend).
|
||||
- **Backend**: <http://localhost:8080> (Express).
|
||||
|
||||
Environment variables (backend service):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|-------------|----------------------------|-------------|
|
||||
|------------------|---------------------------|--------------------------------------------|
|
||||
| `PORT` | `8080` | Backend listen port. |
|
||||
| `CORS_ORIGIN` | `http://localhost:3000` | Allowed origin for CORS. |
|
||||
| `AI_BASE_URL` | *(unset)* | OpenAI-compatible base URL (local LLMs). |
|
||||
| `AI_MODEL` | `gpt-4o-mini` | Model ID for the AI agent. |
|
||||
| `OPENAI_API_KEY` | *(unset)* | Required when using OpenAI directly. |
|
||||
|
||||
For self-hosting (e.g. Tailscale/HTTPS): set `CORS_ORIGIN` to your frontend URL; put Caddy or Nginx in front for TLS if needed.
|
||||
|
||||
**Dev with Docker (frontend hot reload):** use `frontend/Dockerfile.dev` and mount `./frontend` as a volume, or run `cd frontend && npm run dev` locally.
|
||||
For self-hosting (e.g., Tailscale/HTTPS): set `CORS_ORIGIN` to your frontend URL and put Caddy or Nginx in front for TLS.
|
||||
|
||||
---
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
|--------|-------------|
|
||||
| `cd frontend && npm run dev` | Vite dev server. |
|
||||
|--------------------------------------|----------------------------------------|
|
||||
| `cd frontend && npm run dev` | Vite dev server (port 3000). |
|
||||
| `cd frontend && npm run build` | Build frontend for production. |
|
||||
| `cd frontend && npm run preview` | Preview production build. |
|
||||
| `cd backend && npm run dev` | Backend with `--watch`. |
|
||||
| `cd frontend && npm run test` | Run tests in watch mode (Vitest). |
|
||||
| `cd frontend && npm run test:run` | Run tests once (CI). |
|
||||
| `cd backend && npm run dev` | Backend with `tsx --watch`. |
|
||||
| `cd backend && npm start` | Backend production run. |
|
||||
| `docker compose up --build` | Run frontend + backend in Docker. |
|
||||
| `docker compose up --build` | Run full stack in Docker. |
|
||||
|
||||
---
|
||||
|
||||
## Backend API (no DB)
|
||||
## Backend API
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/health` | Health check (e.g. for Docker). |
|
||||
| POST | `/api/agent` | Run AI agent; body `{ "prompt", "context?", "contextNodes?" }` → `{ "markdown" }`. |
|
||||
| Method | Path | Body / Response |
|
||||
|--------|-----------------------|---------------------------------------------------------------------------------|
|
||||
| GET | `/health` | `{ ok: true, timestamp: number }` — health check for Docker/orchestration. |
|
||||
| POST | `/api/agent` | Body `{ prompt, context?, contextNodes? }` → `{ markdown }` (one-shot). |
|
||||
| POST | `/api/agent/stream` | Body `{ prompt, context?, contextNodes? }` → SSE text stream (streaming). |
|
||||
|
||||
---
|
||||
|
||||
## Agent node (local LLM or OpenAI)
|
||||
|
||||
The **Agent** node uses an OpenAI-compatible API. You can use:
|
||||
AI connection settings are configured **directly in the UI** — open the sidebar and go to **AI Settings**. You can switch between providers without restarting the server.
|
||||
|
||||
**1. Local LLM (e.g. LM Studio)**
|
||||
The backend reads its AI config from environment variables as a fallback:
|
||||
|
||||
1. Install [LM Studio](https://lmstudio.ai/) and load a model.
|
||||
2. Start the local server: in LM Studio open the **Developer** tab and run the **Local Server** (default: `http://localhost:1234`).
|
||||
3. In the project root or `backend/`, set:
|
||||
### Local LLM (e.g. LM Studio)
|
||||
|
||||
1. Install [LM Studio](https://lmstudio.ai/), load a model, and start the local server (default: `http://localhost:1234`).
|
||||
2. Set env vars (backend):
|
||||
|
||||
```bash
|
||||
export AI_BASE_URL=http://localhost:1234/v1
|
||||
# Optional: set to the model name shown in LM Studio (e.g. the loaded model id). Default is "local-model".
|
||||
export AI_MODEL=your-model-name
|
||||
export AI_MODEL=your-model-name # optional; matches the name shown in LM Studio
|
||||
```
|
||||
|
||||
4. Start the backend (`cd backend && npm run dev`). The Agent node will use your local model.
|
||||
### OpenAI
|
||||
|
||||
**2. OpenAI**
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export AI_MODEL=gpt-4o-mini # optional; defaults to gpt-4o-mini
|
||||
```
|
||||
|
||||
Set `OPENAI_API_KEY` to your API key. The backend will use `gpt-4o-mini` unless you set `AI_MODEL`.
|
||||
---
|
||||
|
||||
**Env summary (backend)**
|
||||
## Contribute
|
||||
|
||||
| Variable | When to use | Description |
|
||||
|----------|--------------|-------------|
|
||||
| `AI_BASE_URL` | Local LLM (LM Studio, Ollama, etc.) | OpenAI-compatible base URL, e.g. `http://localhost:1234/v1`. |
|
||||
| `AI_MODEL` | Optional | Model id (for local: use the name shown in LM Studio; for OpenAI: e.g. `gpt-4o-mini`). |
|
||||
| `OPENAI_API_KEY` | OpenAI only | Your OpenAI API key. Not required when using `AI_BASE_URL` only. |
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — architecture overview, key patterns, module map
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — coding standards, branch workflow, test commands
|
||||
- [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md) — bottleneck analysis and improvement plan
|
||||
- [docs/CODE_REVIEW_CHECKLIST.md](docs/CODE_REVIEW_CHECKLIST.md) — PR review checklist
|
||||
- [docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md](docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md) — node plugin system design
|
||||
|
||||
*Thank you for contributing!*
|
||||
|
||||
48
docs/CODE_REVIEW_CHECKLIST.md
Normal file
48
docs/CODE_REVIEW_CHECKLIST.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Code Review Checklist
|
||||
|
||||
Use this checklist during code reviews to ensure high standards of readability, maintainability, and performance.
|
||||
|
||||
## 1. Readability
|
||||
|
||||
- [ ] **Naming**: Variables, functions, and components use descriptive, consistent names (camelCase for functions/variables, PascalCase for components).
|
||||
- [ ] **JSDoc**: All public APIs have complete JSDoc comments with `@param`, `@returns`, and `@example` tags.
|
||||
- [ ] **Line Length**: No line exceeds 120 characters; wrap long lines for readability.
|
||||
- [ ] **Comments**: Explain *why* something is done, not just *what* is done. Avoid redundant comments.
|
||||
- [ ] **Whitespace**: Consistent spacing and indentation (2 spaces for TypeScript/JSX).
|
||||
|
||||
## 2. Maintainability
|
||||
|
||||
- [ ] **Component Structure**: Large components are split into smaller, focused sub‑components.
|
||||
- [ ] **Hooks**: Custom hooks encapsulate reusable logic and are named with `use` prefix.
|
||||
- [ ] **Utility Functions**: Pure functions live in `src/lib/` and are exported for reuse.
|
||||
- [ ] **Type Safety**: All function signatures include explicit TypeScript types; no `any` usage.
|
||||
- [ ] **Import Order**: Core → Library → Project → Relative imports; alphabetical within groups.
|
||||
|
||||
## 3. Performance
|
||||
|
||||
- [ ] **Memoization**: Expensive calculations use `useMemo`; event handlers use `useCallback`.
|
||||
- [ ] **Lazy Loading**: Heavy modules are loaded via `import()`; code splitting is configured in Vite.
|
||||
- [ ] **Virtualization**: Large lists use `react-window` or similar for efficient rendering.
|
||||
- [ ] **State Granularity**: State updates target only the minimal portion of state; avoid unnecessary re‑renders.
|
||||
- [ ] **Bundle Size**: No unused dependencies; assets are compressed (gzip/brotli) in production.
|
||||
|
||||
## 4. Accessibility & Security
|
||||
|
||||
- [ ] **ARIA**: Semantic HTML and ARIA attributes are present for interactive elements.
|
||||
- [ ] **Input Validation**: User inputs are validated before processing; no hard‑coded secrets.
|
||||
- [ ] **Error Handling**: Errors are caught and logged appropriately; user‑friendly messages are shown.
|
||||
|
||||
## 5. Testing
|
||||
|
||||
- [ ] **Unit Tests**: Cover all new logic with tests; aim for ≥ 80 % coverage on critical paths.
|
||||
- [ ] **Integration Tests**: Verify that components interact correctly with state/store.
|
||||
- [ ] **Test Naming**: Test files end with `.test.tsx` and describe the behavior being tested.
|
||||
|
||||
## 6. Documentation Links
|
||||
|
||||
- [ ] Architecture Overview: [[ARCHITECTURE.md](ARCHITECTURE.md)]
|
||||
- [ ] Performance Plan: [[PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md)]
|
||||
- [ ] Junior Developer Quickstart: [[QUICKSTART_FOR_JUNIORS.md](QUICKSTART_FOR_JUNIORS.md)]
|
||||
- [ ] Contribution Guide: [[CONTRIBUTING.md](CONTRIBUTING.md)]
|
||||
|
||||
*Reviewers should mark any failing items as **[ ]** and request changes before approving.*
|
||||
99
frontend/docs/QUICKSTART_FOR_JUNIORS.md
Normal file
99
frontend/docs/QUICKSTART_FOR_JUNIORS.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Junior Developer Quickstart
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This guide explains how to set up the development environment, run the application, and make your first contribution.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
- **Node.js** (v18 or later)
|
||||
- **pnpm** (package manager)
|
||||
- **Docker** (for backend services, optional)
|
||||
- **Git** (version control)
|
||||
|
||||
## 3. Repository Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/your-org/zui.git
|
||||
cd zui
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 4. Running the Application
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open <http://localhost:3000> to see the app.
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
The backend API will be available at <http://localhost:5000>.
|
||||
|
||||
## 5. Making Your First Contribution
|
||||
|
||||
1. **Create a feature branch**:
|
||||
|
||||
```bash
|
||||
git checkout -b feat/your-first-contribution
|
||||
```
|
||||
|
||||
2. **Make a small change**:
|
||||
- Improve a README typo.
|
||||
- Add a missing JSDoc comment.
|
||||
- Fix a minor bug.
|
||||
3. **Run the appropriate tests** to ensure your change doesn't break anything:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
4. **Commit your changes** with a clear message:
|
||||
|
||||
```bash
|
||||
git commit -am "feat: brief description of change"
|
||||
```
|
||||
|
||||
5. **Push the branch** to GitHub:
|
||||
|
||||
```bash
|
||||
git push origin feat/your-first-contribution
|
||||
```
|
||||
|
||||
6. **Open a Pull Request** on GitHub, linking the relevant issue.
|
||||
|
||||
## 6. Coding Standards
|
||||
|
||||
- Follow the patterns in **ARCHITECTURE.md** and **CONTRIBUTING.md**.
|
||||
- Use TypeScript with strict mode.
|
||||
- Add JSDoc for all public APIs.
|
||||
- Keep changes small and focused.
|
||||
|
||||
## 7. Helpful Links
|
||||
|
||||
- **ARCHITECTURE.md** - High-level architecture overview.
|
||||
- **PERFORMANCE_IMPROVEMENTS.md** - Performance improvement plan.
|
||||
- **CODE_REVIEW_CHECKLIST.md** - Checklist for reviewers.
|
||||
|
||||
## 8. FAQ
|
||||
|
||||
**Q:** Do I need to write tests?
|
||||
**A:** For bug fixes and new features, yes. Unit tests and integration tests are located under `frontend/src/app/**/*.test.tsx`.
|
||||
|
||||
**Q:** How do I run the linter?
|
||||
**A:** `pnpm lint`.
|
||||
|
||||
**Q:** Where can I find more documentation?
|
||||
**A:** See the `docs/` directory and the repository wiki.
|
||||
|
||||
---\n*Happy coding!*
|
||||
@@ -3,7 +3,7 @@
|
||||
* with initial graph from recollection storage (or example). Save is explicit via save().
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
||||
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||
@@ -59,5 +59,16 @@ export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGra
|
||||
}, SAVING_DISPLAY_MS)
|
||||
}, [recollectionId])
|
||||
|
||||
// Auto-save after 5 seconds of inactivity when there are unsaved changes.
|
||||
// Prevents data loss if the user closes the tab without pressing Ctrl+S.
|
||||
const AUTO_SAVE_DELAY_MS = 5000
|
||||
const saveRef = useRef(save)
|
||||
saveRef.current = save
|
||||
useEffect(() => {
|
||||
if (!isDirty || !recollectionId) return
|
||||
const timer = setTimeout(() => saveRef.current(), AUTO_SAVE_DELAY_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isDirty, recollectionId, currentSerialized])
|
||||
|
||||
return { ...result, save, saveStatus }
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function RecollectionSidebar() {
|
||||
>
|
||||
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
|
||||
<SidebarGroup>
|
||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
||||
<div className="flex items-center justify-between gap-2 py-1.5">
|
||||
<SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -14,9 +14,30 @@
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Tree, NodeApi, RowRendererProps, TreeApi } from 'react-arborist'
|
||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||
import {
|
||||
Tree,
|
||||
NodeApi,
|
||||
NodeRendererProps,
|
||||
RowRendererProps,
|
||||
TreeApi,
|
||||
type CursorProps,
|
||||
} from 'react-arborist'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FileText, ChevronRight, ChevronDown, Plus, MoreHorizontal, Trash2, Pencil, Search, ChevronsDownUp, ChevronsUpDown, X, GripVertical, Folder } from 'lucide-react'
|
||||
import {
|
||||
FileText,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Search,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
X,
|
||||
GripVertical,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -28,13 +49,11 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import type { LogosPageMeta, LogosPageId } from '../state/recollectionStore'
|
||||
import type { LogosPageMeta } from '../state/recollectionStore'
|
||||
import { removeLogosPageContent } from '../state/recollectionStore'
|
||||
|
||||
// Tree node data structure
|
||||
type TreeNode = {
|
||||
id: string
|
||||
data: LogosPageMeta
|
||||
// Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object).
|
||||
type TreeNode = LogosPageMeta & {
|
||||
children?: TreeNode[]
|
||||
isFolder: boolean
|
||||
}
|
||||
@@ -47,8 +66,7 @@ function buildTree(pages: LogosPageMeta[]): TreeNode[] {
|
||||
// First pass: create all nodes
|
||||
pages.forEach((page) => {
|
||||
pageMap.set(page.id, {
|
||||
id: page.id,
|
||||
data: page,
|
||||
...page,
|
||||
children: [],
|
||||
isFolder: false,
|
||||
})
|
||||
@@ -70,7 +88,7 @@ function buildTree(pages: LogosPageMeta[]): TreeNode[] {
|
||||
|
||||
// Sort children by position
|
||||
function sortNodes(nodes: TreeNode[]) {
|
||||
nodes.sort((a, b) => a.data.position - b.data.position)
|
||||
nodes.sort((a, b) => a.position - b.position)
|
||||
nodes.forEach((node) => sortNodes(node.children || []))
|
||||
}
|
||||
sortNodes(rootNodes)
|
||||
@@ -121,59 +139,91 @@ function EditableTitle({
|
||||
)
|
||||
}
|
||||
|
||||
// Tree row renderer
|
||||
function TreeRow({
|
||||
node,
|
||||
innerRef,
|
||||
attrs,
|
||||
children,
|
||||
}: RowRendererProps<TreeNode>) {
|
||||
const ROW_INDENT_PX = 20
|
||||
const ROW_GUTTER_PX = 8
|
||||
|
||||
/**
|
||||
* Line cursor = “insert as sibling at this indent” (not “drop into folder”).
|
||||
* Triangle + dashed rail read as a slot between rows; matches sidebar theme.
|
||||
*/
|
||||
const LogosDropCursor = React.memo(function LogosDropCursor({ top, left, indent }: CursorProps) {
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute z-20 flex items-center gap-1.5"
|
||||
style={{
|
||||
top: top - 5,
|
||||
left: left + ROW_GUTTER_PX,
|
||||
right: Math.max(indent, ROW_GUTTER_PX),
|
||||
}}
|
||||
>
|
||||
<span className="flex h-3 w-2.5 shrink-0 items-center justify-center text-primary drop-shadow-sm">
|
||||
<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor" aria-hidden>
|
||||
<path d="M0 5 L9 1.5 L9 8.5 Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="flex h-3 min-w-[2rem] flex-1 items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'h-0 w-full border-t-2 border-dashed border-primary',
|
||||
'shadow-[0_1px_0_0_hsl(var(--primary)/0.35)]',
|
||||
'motion-safe:animate-[tree-drop-line-pulse_1.8s_ease-in-out_infinite]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Drag ref on a compact handle at the start of the row; handle is a floating pill shown on row hover
|
||||
* (always visible on coarse pointers). Title/icon strip is click-to-navigate only.
|
||||
*/
|
||||
function LogosTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const { tree, activePageId, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const baseLogos = `${base}/logos`
|
||||
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
|
||||
|
||||
const handleToggle = useCallback((e: React.MouseEvent) => {
|
||||
const handleToggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
node.toggle()
|
||||
}, [node])
|
||||
},
|
||||
[node]
|
||||
)
|
||||
|
||||
const handleSelect = useCallback((e: React.MouseEvent) => {
|
||||
const handleSelect = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!node.state.isEditing && !node.state.isDragging) {
|
||||
handleSelectPage(node.data.id)
|
||||
navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`)
|
||||
}
|
||||
}, [handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging])
|
||||
},
|
||||
[handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging]
|
||||
)
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
node.edit()
|
||||
}, [node])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
// Get all descendants to delete
|
||||
const toDelete = new Set<string>()
|
||||
function collectDescendants(id: string) {
|
||||
toDelete.add(id)
|
||||
tree.forEach((p) => {
|
||||
if (p.parentId === id) {
|
||||
collectDescendants(p.id)
|
||||
}
|
||||
if (p.parentId === id) collectDescendants(p.id)
|
||||
})
|
||||
}
|
||||
collectDescendants(node.data.id)
|
||||
|
||||
// Remove content from storage for all deleted pages
|
||||
if (recollectionId) {
|
||||
toDelete.forEach((pageId) => {
|
||||
removeLogosPageContent(recollectionId, pageId)
|
||||
})
|
||||
}
|
||||
|
||||
// Remove from tree
|
||||
handleTreeChange(tree.filter((p) => !toDelete.has(p.id)))
|
||||
}, [tree, handleTreeChange, node.data.id, recollectionId])
|
||||
|
||||
@@ -190,41 +240,57 @@ function TreeRow({
|
||||
position: maxPos + 1,
|
||||
}
|
||||
handleTreeChange([...tree, newPage])
|
||||
// Expand parent if collapsed
|
||||
if (!node.isOpen) {
|
||||
node.open()
|
||||
}
|
||||
if (!node.isOpen) node.open()
|
||||
}, [tree, handleTreeChange, node])
|
||||
|
||||
const hasChildren = (node.children?.length || 0) > 0
|
||||
const level = node.level
|
||||
const pageTitle = node.data.title || 'Untitled'
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */}
|
||||
<div
|
||||
ref={innerRef}
|
||||
{...attrs}
|
||||
style={{
|
||||
...attrs.style,
|
||||
paddingLeft: `${level * 20 + 8}px`,
|
||||
}}
|
||||
className={cn(
|
||||
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md',
|
||||
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
||||
node.state.isDragging && 'opacity-50',
|
||||
node.state.willReceiveDrop && 'bg-sidebar-accent/50'
|
||||
'flex shrink-0 items-center justify-center overflow-hidden',
|
||||
'transition-[width,opacity] duration-200 ease-out',
|
||||
'w-0 opacity-0 group-hover:w-4 group-hover:opacity-100',
|
||||
'[@media(pointer:coarse)]:w-5 [@media(pointer:coarse)]:opacity-100',
|
||||
node.state.isDragging && 'w-4 opacity-100 [@media(pointer:coarse)]:w-5',
|
||||
node.state.isEditing && 'pointer-events-none w-0 opacity-0'
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
{/* Visual hierarchy indicator for nested items */}
|
||||
{level > 0 && (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 border-l-2 border-sidebar-border/50"
|
||||
style={{ left: `${(level - 1) * 20 + 16}px` }}
|
||||
/>
|
||||
ref={dragHandle}
|
||||
style={{ ...style, paddingLeft: 0 }}
|
||||
title={node.state.isEditing ? undefined : 'Drag to reorder'}
|
||||
className={cn(
|
||||
'logos-tree-drag-handle group/drag',
|
||||
'relative flex h-6 w-4 shrink-0 touch-none items-center justify-center rounded-md',
|
||||
'[@media(pointer:coarse)]:h-7 [@media(pointer:coarse)]:w-5',
|
||||
'border border-sidebar-border/50 bg-sidebar-accent/40',
|
||||
'text-sidebar-foreground/70',
|
||||
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out',
|
||||
'hover:border-sidebar-border hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
'hover:shadow-sm',
|
||||
'active:scale-[0.97] active:cursor-grabbing',
|
||||
'cursor-grab outline-none',
|
||||
'dark:border-sidebar-border/55 dark:bg-sidebar-accent/35 dark:text-sidebar-foreground/75',
|
||||
'dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground',
|
||||
node.state.isDragging &&
|
||||
'cursor-grabbing border-primary/55 bg-primary/18 text-primary shadow-sm ring-1 ring-primary/30 dark:border-primary/50 dark:bg-primary/22 dark:text-primary'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical
|
||||
className={cn(
|
||||
'size-3 shrink-0 text-current transition-opacity duration-150 opacity-85 group-hover/drag:opacity-100',
|
||||
node.state.isDragging && 'opacity-100'
|
||||
)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse toggle for folders */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -241,41 +307,39 @@ function TreeRow({
|
||||
<div className="w-4 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
{node.data.isFolder ? (
|
||||
<Folder className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
) : (
|
||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-[28px] min-w-0 flex-1 items-center gap-1 rounded-md px-0.5 -mx-0.5',
|
||||
node.state.isEditing ? 'cursor-text' : 'cursor-pointer hover:bg-sidebar-accent/40'
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
|
||||
{/* Title - Editable */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
{node.state.isEditing ? (
|
||||
<EditableTitle
|
||||
title={node.data.data.title}
|
||||
title={node.data.title}
|
||||
onSave={(newTitle) => {
|
||||
if (newTitle.trim()) {
|
||||
handleTreeChange(
|
||||
tree.map((p) =>
|
||||
p.id === node.data.id ? { ...p, title: newTitle.trim() } : p
|
||||
)
|
||||
tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p))
|
||||
)
|
||||
}
|
||||
node.submit(newTitle)
|
||||
}}
|
||||
onCancel={() => {
|
||||
node.reset()
|
||||
}}
|
||||
onCancel={() => node.reset()}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate block select-none">
|
||||
{node.data.data.title || 'Untitled'}
|
||||
</span>
|
||||
<span className="block truncate select-none">{pageTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions - Only visible on hover */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||
@@ -303,10 +367,7 @@ function TreeRow({
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -317,10 +378,52 @@ function TreeRow({
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */
|
||||
function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) {
|
||||
const { pathname } = useLocation()
|
||||
const { activePageId } = useRecollectionSidebar()
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const baseLogos = `${base}/logos`
|
||||
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
|
||||
const level = node.level
|
||||
const indentPx = ROW_GUTTER_PX + level * ROW_INDENT_PX
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={innerRef}
|
||||
{...attrs}
|
||||
style={{
|
||||
...attrs.style,
|
||||
paddingLeft: `${indentPx}px`,
|
||||
}}
|
||||
className={cn(
|
||||
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md transition-[box-shadow,background-color,ring-color] duration-150',
|
||||
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
||||
node.state.isDragging && 'cursor-grabbing opacity-45',
|
||||
// Folder drop target: index === null in library — reads as “open container”, not a line between rows
|
||||
node.state.willReceiveDrop &&
|
||||
'cursor-copy bg-sidebar-accent/30 ring-2 ring-inset ring-dashed ring-sidebar-ring/80 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]'
|
||||
)}
|
||||
title={node.state.willReceiveDrop ? 'Release to drop inside this folder' : undefined}
|
||||
>
|
||||
{level > 0 && (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-0 border-l-2 border-sidebar-border/45"
|
||||
style={{ left: `${ROW_GUTTER_PX + (level - 1) * ROW_INDENT_PX + ROW_INDENT_PX / 2}px` }}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TreeBrowser() {
|
||||
const { tree, handleTreeChange } = useRecollectionSidebar()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
||||
const [treeContainerHeight, treeContainerRef] = useResizeHeight(600)
|
||||
|
||||
// Build tree from flat structure
|
||||
const treeNodes = useTreeNodes(tree)
|
||||
@@ -334,7 +437,7 @@ export function TreeBrowser() {
|
||||
function filterNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
const result: TreeNode[] = []
|
||||
for (const node of nodes) {
|
||||
const matches = node.data.title.toLowerCase().includes(searchTerm)
|
||||
const matches = node.title.toLowerCase().includes(searchTerm)
|
||||
const children = filterNodes(node.children || [])
|
||||
|
||||
if (matches || children.length > 0) {
|
||||
@@ -365,40 +468,46 @@ export function TreeBrowser() {
|
||||
return folderIds
|
||||
}, [treeNodes])
|
||||
|
||||
// Handle drag and drop reordering
|
||||
// Handle drag and drop reordering (react-arborist onMove)
|
||||
const handleMove = useCallback(
|
||||
({ dragIds, parentId, index }: { dragIds: string[]; parentId: string | null; index: number }) => {
|
||||
({
|
||||
dragIds,
|
||||
parentId: newParentId,
|
||||
index,
|
||||
}: {
|
||||
dragIds: string[]
|
||||
parentId: string | null
|
||||
index: number
|
||||
}) => {
|
||||
const dragId = dragIds[0]
|
||||
if (!dragId) return
|
||||
|
||||
// Find the dragged page
|
||||
const draggedPage = tree.find((p) => p.id === dragId)
|
||||
if (!draggedPage) return
|
||||
const dragged = tree.find((p) => p.id === dragId)
|
||||
if (!dragged) return
|
||||
|
||||
// Get all siblings at the new location (excluding the dragged item)
|
||||
const siblings = tree.filter((p) => p.parentId === parentId && p.id !== dragId)
|
||||
const oldParentId = dragged.parentId
|
||||
const moved: LogosPageMeta = { ...dragged, parentId: newParentId }
|
||||
|
||||
// Sort siblings by current position
|
||||
siblings.sort((a, b) => a.position - b.position)
|
||||
const newSiblings = tree
|
||||
.filter((p) => p.parentId === newParentId && p.id !== dragId)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
newSiblings.splice(index, 0, moved)
|
||||
|
||||
// Insert dragged item at the new index
|
||||
siblings.splice(index, 0, { ...draggedPage, parentId, position: index })
|
||||
|
||||
// Create updated tree with new positions
|
||||
const updatedTree = tree.map((p) => {
|
||||
// Find the item in the siblings array
|
||||
const siblingIndex = siblings.findIndex((s) => s.id === p.id)
|
||||
|
||||
if (siblingIndex >= 0) {
|
||||
// This item is in the affected parent - update its position
|
||||
return { ...p, parentId: siblings[siblingIndex].parentId, position: siblingIndex }
|
||||
}
|
||||
|
||||
// Item is not affected by the move
|
||||
return p
|
||||
const updates = new Map<string, LogosPageMeta>()
|
||||
newSiblings.forEach((p, i) => {
|
||||
updates.set(p.id, { ...p, parentId: newParentId, position: i })
|
||||
})
|
||||
|
||||
handleTreeChange(updatedTree)
|
||||
if (oldParentId !== newParentId) {
|
||||
tree
|
||||
.filter((p) => p.parentId === oldParentId && p.id !== dragId)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.forEach((p, i) => {
|
||||
updates.set(p.id, { ...p, position: i })
|
||||
})
|
||||
}
|
||||
|
||||
handleTreeChange(tree.map((p) => updates.get(p.id) ?? p))
|
||||
},
|
||||
[tree, handleTreeChange]
|
||||
)
|
||||
@@ -418,10 +527,33 @@ export function TreeBrowser() {
|
||||
setSearchQuery('')
|
||||
}, [])
|
||||
|
||||
// Fixed prop shape: react-arborist TreeProvider uses [...Object.values(treeProps), …] as useMemo deps;
|
||||
// varying key counts on the props object trigger "dependency array changed size" warnings.
|
||||
const arboristTreeProps = useMemo(
|
||||
() => ({
|
||||
data: filteredTree,
|
||||
idAccessor: 'id' as const,
|
||||
childrenAccessor: 'children' as const,
|
||||
width: '100%' as const,
|
||||
height: Math.max(treeContainerHeight, 100),
|
||||
rowHeight: 32,
|
||||
indent: ROW_INDENT_PX,
|
||||
renderRow: TreeRow,
|
||||
initialOpenState,
|
||||
onMove: handleMove,
|
||||
disableDrag: Boolean(searchQuery),
|
||||
disableDrop: Boolean(searchQuery),
|
||||
className: 'react-arborist-tree',
|
||||
renderCursor: LogosDropCursor,
|
||||
children: LogosTreeNode,
|
||||
}),
|
||||
[filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Search Toolbar */}
|
||||
<div className="flex items-center gap-1.5 px-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
|
||||
<Input
|
||||
@@ -469,23 +601,8 @@ export function TreeBrowser() {
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden px-2">
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
data={filteredTree}
|
||||
idAccessor="id"
|
||||
childrenAccessor="children"
|
||||
width="100%"
|
||||
height={600}
|
||||
rowHeight={32}
|
||||
indent={0}
|
||||
renderRow={TreeRow}
|
||||
initialOpenState={initialOpenState}
|
||||
onMove={handleMove}
|
||||
disableDrag={!!searchQuery}
|
||||
disableDrop={!!searchQuery}
|
||||
className="react-arborist-tree"
|
||||
/>
|
||||
<div ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
|
||||
<Tree ref={treeRef} {...arboristTreeProps} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,13 @@ import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/g
|
||||
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
|
||||
import { isReachable, resolveExtendsRef, getTemplateRefs, type NodeLike } from '@/lib/graph/templateRefs'
|
||||
|
||||
// Module-level cache for resolved Nunjucks output.
|
||||
// Keyed by a fingerprint of all inputs (template contents + variable/data values + function bodies).
|
||||
// Avoids re-running the Nunjucks environment when the same inputs are seen again (e.g. undo/redo,
|
||||
// multiple rendering nodes sharing the same config, rapid edits cycling back to a prior value).
|
||||
const MAX_RESOLVE_CACHE = 200
|
||||
const resolveCache = new Map<string, ResolvedContentResult>()
|
||||
|
||||
export async function getResolvedContentForConfig(context: SourceRenderingLogicContext): Promise<ResolvedContentResult> {
|
||||
const { nodes, edges, sourceNodeId, renderNodeId } = context
|
||||
const srcId = sourceNodeId
|
||||
@@ -107,6 +114,24 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
||||
}
|
||||
}
|
||||
|
||||
// Build cache key from all inputs that affect the resolved output.
|
||||
const allTemplateContents = [...configIdsUsed]
|
||||
.map((cid) => {
|
||||
const node = nodes.find((n) => n.id === cid)
|
||||
return getConfigContent((node?.data ?? undefined) as Record<string, unknown> | undefined)
|
||||
})
|
||||
.join('\x00')
|
||||
const allFunctionBodies = [...functionIdsToRegister]
|
||||
.map((fid) => {
|
||||
const node = nodes.find((n) => n.id === fid)
|
||||
return ((node?.data as Record<string, unknown>)?.body as string) ?? ''
|
||||
})
|
||||
.join('\x00')
|
||||
const cacheKey = `${srcId}\x01${JSON.stringify(nunjucksContext)}\x01${allTemplateContents}\x01${allFunctionBodies}`
|
||||
|
||||
const cached = resolveCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const env = new nunjucks.Environment([configLoader], { autoescape: false })
|
||||
|
||||
const formatFilterResult = (r: unknown): string => {
|
||||
@@ -248,7 +273,13 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
||||
return
|
||||
}
|
||||
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
|
||||
resolve({ resolved, outputTypeId })
|
||||
const result: ResolvedContentResult = { resolved, outputTypeId }
|
||||
// FIFO eviction when cache is full
|
||||
if (resolveCache.size >= MAX_RESOLVE_CACHE) {
|
||||
resolveCache.delete(resolveCache.keys().next().value as string)
|
||||
}
|
||||
resolveCache.set(cacheKey, result)
|
||||
resolve(result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
@@ -175,8 +176,11 @@ export function useRenderingNodeState(
|
||||
|
||||
// Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant data).
|
||||
// Context only exposes a ref, so we wouldn't re-render when another node updates otherwise.
|
||||
const storeNodes = useCanvasStore((s) => s.graph.nodes)
|
||||
const storeEdges = useCanvasStore((s) => s.graph.edges)
|
||||
// Single combined subscription (vs two separate) halves listener overhead; useShallow prevents
|
||||
// re-renders when non-graph slices (ui/path) change.
|
||||
const { storeNodes, storeEdges } = useCanvasStore(
|
||||
useShallow((s) => ({ storeNodes: s.graph.nodes, storeEdges: s.graph.edges }))
|
||||
)
|
||||
const nodes = storeNodes.length > 0 ? storeNodes : contextNodes
|
||||
const edges = storeEdges.length > 0 ? storeEdges : contextEdges
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@ export type ConfigType = {
|
||||
|
||||
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
||||
const KROKI_TIMEOUT_MS = 15000
|
||||
const KROKI_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
// In-flight deduplication: if the same PlantUML content is already being fetched,
|
||||
// reuse the existing promise instead of firing a duplicate request.
|
||||
const krokiInflight = new Map<string, Promise<string>>()
|
||||
// TTL result cache: avoids re-fetching identical content within the TTL window.
|
||||
const krokiCache = new Map<string, { result: string; cachedAt: number }>()
|
||||
|
||||
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
||||
@@ -172,6 +179,18 @@ const BUILTIN_CONFIG_TYPES: ConfigType[] = [
|
||||
language: 'plantuml',
|
||||
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
||||
render: async (content: string) => {
|
||||
// 1. TTL cache hit
|
||||
const cached = krokiCache.get(content)
|
||||
if (cached && Date.now() - cached.cachedAt < KROKI_CACHE_TTL_MS) {
|
||||
return cached.result
|
||||
}
|
||||
|
||||
// 2. In-flight deduplication: reuse an existing request for the same content
|
||||
const inflight = krokiInflight.get(content)
|
||||
if (inflight) return inflight
|
||||
|
||||
// 3. New request
|
||||
const fetchPromise = (async () => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
|
||||
try {
|
||||
@@ -189,7 +208,9 @@ const BUILTIN_CONFIG_TYPES: ConfigType[] = [
|
||||
}
|
||||
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
||||
}
|
||||
return res.text()
|
||||
const svg = await res.text()
|
||||
krokiCache.set(content, { result: svg, cachedAt: Date.now() })
|
||||
return svg
|
||||
} catch (err: unknown) {
|
||||
clearTimeout(timeoutId)
|
||||
if (err instanceof Error) {
|
||||
@@ -201,7 +222,13 @@ const BUILTIN_CONFIG_TYPES: ConfigType[] = [
|
||||
}
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
krokiInflight.delete(content)
|
||||
}
|
||||
})()
|
||||
|
||||
krokiInflight.set(content, fetchPromise)
|
||||
return fetchPromise
|
||||
},
|
||||
outputMenuDescriptor: {
|
||||
submenuLabel: 'Export',
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import React from 'react'
|
||||
import React, { lazy, Suspense } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
import { ThemeProvider } from './lib/themeContext'
|
||||
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
|
||||
import { registerBuiltinConfigTypes } from './lib/graph/configTypes'
|
||||
import { KosmosPage } from './app/kosmos/KosmosPage'
|
||||
import { RecollectionsPage } from './app/recollections/RecollectionsPage'
|
||||
import { RecollectionLayout } from './app/recollections/RecollectionLayout'
|
||||
import { LogosPage } from './app/recollections/logos/LogosPage'
|
||||
import { FluxRoute } from './app/recollections/flux/FluxRoute'
|
||||
import { NotFoundPage } from './app/NotFoundPage'
|
||||
|
||||
import './lib/prismSetup'
|
||||
import 'prismjs/themes/prism.css'
|
||||
import './styles.css'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
// Lazy-load heavy route components so their bundles are fetched on first navigation,
|
||||
// not at initial app load. RecollectionLayout and NotFoundPage stay eager (lightweight).
|
||||
const KosmosPage = lazy(() => import('./app/kosmos/KosmosPage').then(m => ({ default: m.KosmosPage })))
|
||||
const RecollectionsPage = lazy(() => import('./app/recollections/RecollectionsPage').then(m => ({ default: m.RecollectionsPage })))
|
||||
const LogosPage = lazy(() => import('./app/recollections/logos/LogosPage').then(m => ({ default: m.LogosPage })))
|
||||
const FluxRoute = lazy(() => import('./app/recollections/flux/FluxRoute').then(m => ({ default: m.FluxRoute })))
|
||||
|
||||
registerBuiltinConfigTypes()
|
||||
registerBuiltinNodes()
|
||||
|
||||
@@ -23,6 +27,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground text-sm">Loading…</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<KosmosPage />}>
|
||||
<Route index element={<Navigate to="/recollections" replace />} />
|
||||
@@ -37,6 +42,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -401,6 +401,19 @@ pre {
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Logos tree drop line (LogosDropCursor in TreeBrowser.tsx) */
|
||||
@keyframes tree-drop-line-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* React Arborist tree styling for sidebar integration */
|
||||
.react-arborist-tree {
|
||||
background: transparent !important;
|
||||
@@ -443,6 +456,15 @@ pre {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Drag handle: restore focus ring (tree uses outline:none on all descendants) */
|
||||
.react-arborist-tree .logos-tree-drag-handle:focus-visible {
|
||||
outline: 2px solid hsl(var(--sidebar-ring)) !important;
|
||||
outline-offset: 2px;
|
||||
box-shadow:
|
||||
0 0 0 2px hsl(var(--sidebar-background)),
|
||||
0 2px 8px hsl(var(--sidebar-ring) / 0.25);
|
||||
}
|
||||
|
||||
/* Hide default react-arborist backgrounds */
|
||||
.react-arborist-tree [data-react-arborist-tree] {
|
||||
background: transparent !important;
|
||||
|
||||
Reference in New Issue
Block a user