Compare commits
16 Commits
7c7d470f15
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 223336c606 | |||
| cbd8f1568b | |||
| 166c7b0b7f | |||
| 2635b45973 | |||
| c3cbe35883 | |||
| bc67d01bc2 | |||
| 96cb3701ba | |||
| 5bce586db6 | |||
| febca67c9b | |||
| 9076a45510 | |||
| be0f743970 | |||
| 3de9a33074 | |||
| 9d38b1df2b | |||
| 9f56b728c0 | |||
| f6193bf180 | |||
| e64e29d773 |
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.*
|
||||||
134
README.md
134
README.md
@@ -1,42 +1,61 @@
|
|||||||
# Zui
|
# 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
|
## Project layout
|
||||||
|
|
||||||
```
|
```text
|
||||||
my-app/
|
zui/
|
||||||
├── frontend/ # React app (Vite, TypeScript)
|
├── frontend/ # React SPA (Vite, TypeScript)
|
||||||
│ ├── Dockerfile # Multi-stage for prod (build → Nginx)
|
|
||||||
│ ├── Dockerfile.dev # Dev with hot reload
|
|
||||||
│ ├── src/
|
│ ├── 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
|
│ ├── nginx.conf
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── backend/ # Node.js/Express API
|
├── backend/ # Node.js/Express API
|
||||||
│ ├── Dockerfile
|
|
||||||
│ ├── src/
|
│ ├── 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
|
│ └── package.json
|
||||||
|
├── docs/
|
||||||
|
│ ├── ARCHITECTURE.md
|
||||||
|
│ ├── PERFORMANCE.md
|
||||||
|
│ ├── NODE_TYPE_EXTENSIBILITY_PROPOSAL.md
|
||||||
|
│ └── CODE_REVIEW_CHECKLIST.md
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── .dockerignore
|
|
||||||
└── .gitignore
|
└── .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)
|
## Run locally (dev)
|
||||||
|
|
||||||
**Frontend only:**
|
**Frontend only** (no AI agent):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm install && npm run dev
|
cd frontend && npm install && npm run dev
|
||||||
# → http://localhost:3000
|
# → http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend + backend** (for AI agent and health):
|
**Frontend + backend** (for AI agent and health check):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1 – backend
|
# Terminal 1 – backend
|
||||||
@@ -45,7 +64,7 @@ cd backend && npm install && npm run dev
|
|||||||
|
|
||||||
# Terminal 2 – frontend
|
# Terminal 2 – frontend
|
||||||
cd frontend && npm install && npm run dev
|
cd frontend && npm install && npm run dev
|
||||||
# → http://localhost:3000 (Vite proxies /api/* and /health to backend)
|
# → http://localhost:3000 (Vite proxies /api/* and /health to backend)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -56,70 +75,79 @@ cd frontend && npm install && npm run dev
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Frontend**: http://localhost:3000 (Nginx; `/api/*` proxied to backend).
|
- **Frontend**: <http://localhost:3000> (Nginx; `/api/*` proxied to backend).
|
||||||
- **Backend**: http://localhost:8080 (Express).
|
- **Backend**: <http://localhost:8080> (Express).
|
||||||
|
|
||||||
Environment variables (backend service):
|
Environment variables (backend service):
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|-------------|----------------------------|-------------|
|
|------------------|---------------------------|--------------------------------------------|
|
||||||
| `PORT` | `8080` | Backend listen port. |
|
| `PORT` | `8080` | Backend listen port. |
|
||||||
| `CORS_ORIGIN` | `http://localhost:3000` | Allowed origin for CORS. |
|
| `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.
|
For self-hosting (e.g., Tailscale/HTTPS): set `CORS_ORIGIN` to your frontend URL and put Caddy or Nginx in front for TLS.
|
||||||
|
|
||||||
**Dev with Docker (frontend hot reload):** use `frontend/Dockerfile.dev` and mount `./frontend` as a volume, or run `cd frontend && npm run dev` locally.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| 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 build` | Build frontend for production. |
|
||||||
| `cd frontend && npm run preview` | Preview production build. |
|
| `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 backend && npm start` | Backend production run. |
|
| `cd frontend && npm run test:run` | Run tests once (CI). |
|
||||||
| `docker compose up --build` | Run frontend + backend in Docker. |
|
| `cd backend && npm run dev` | Backend with `tsx --watch`. |
|
||||||
|
| `cd backend && npm start` | Backend production run. |
|
||||||
|
| `docker compose up --build` | Run full stack in Docker. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backend API (no DB)
|
## Backend API
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Body / Response |
|
||||||
|--------|------|-------------|
|
|--------|-----------------------|---------------------------------------------------------------------------------|
|
||||||
| GET | `/health` | Health check (e.g. for Docker). |
|
| GET | `/health` | `{ ok: true, timestamp: number }` — health check for Docker/orchestration. |
|
||||||
| POST | `/api/agent` | Run AI agent; body `{ "prompt", "context?", "contextNodes?" }` → `{ "markdown" }`. |
|
| 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)
|
## 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.
|
### Local LLM (e.g. LM Studio)
|
||||||
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:
|
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
|
```bash
|
||||||
export AI_BASE_URL=http://localhost:1234/v1
|
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 # optional; matches the name shown in LM Studio
|
||||||
export AI_MODEL=your-model-name
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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 |
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — architecture overview, key patterns, module map
|
||||||
|----------|--------------|-------------|
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — coding standards, branch workflow, test commands
|
||||||
| `AI_BASE_URL` | Local LLM (LM Studio, Ollama, etc.) | OpenAI-compatible base URL, e.g. `http://localhost:1234/v1`. |
|
- [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md) — bottleneck analysis and improvement plan
|
||||||
| `AI_MODEL` | Optional | Model id (for local: use the name shown in LM Studio; for OpenAI: e.g. `gpt-4o-mini`). |
|
- [docs/CODE_REVIEW_CHECKLIST.md](docs/CODE_REVIEW_CHECKLIST.md) — PR review checklist
|
||||||
| `OPENAI_API_KEY` | OpenAI only | Your OpenAI API key. Not required when using `AI_BASE_URL` only. |
|
- [docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md](docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md) — node plugin system design
|
||||||
|
|
||||||
|
*Thank you for contributing!*
|
||||||
|
|||||||
678
backend/package-lock.json
generated
678
backend/package-lock.json
generated
@@ -13,6 +13,13 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0"
|
"express": "^4.21.0"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"tsx": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
@@ -103,6 +110,448 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@opentelemetry/api": {
|
"node_modules/@opentelemetry/api": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
@@ -112,12 +561,120 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/body-parser": {
|
||||||
|
"version": "1.19.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
|
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/connect": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/connect": {
|
||||||
|
"version": "3.4.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/cors": {
|
||||||
|
"version": "2.8.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
|
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/diff-match-patch": {
|
"node_modules/@types/diff-match-patch": {
|
||||||
"version": "1.0.36",
|
"version": "1.0.36",
|
||||||
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
|
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
|
||||||
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
|
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/express": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/body-parser": "*",
|
||||||
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
|
"@types/serve-static": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/express-serve-static-core": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/qs": "*",
|
||||||
|
"@types/range-parser": "*",
|
||||||
|
"@types/send": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/http-errors": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/qs": {
|
||||||
|
"version": "6.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
|
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/range-parser": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/send": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/serve-static": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-errors": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -392,6 +949,48 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.4",
|
||||||
|
"@esbuild/android-arm": "0.27.4",
|
||||||
|
"@esbuild/android-arm64": "0.27.4",
|
||||||
|
"@esbuild/android-x64": "0.27.4",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.4",
|
||||||
|
"@esbuild/darwin-x64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.4",
|
||||||
|
"@esbuild/linux-arm": "0.27.4",
|
||||||
|
"@esbuild/linux-arm64": "0.27.4",
|
||||||
|
"@esbuild/linux-ia32": "0.27.4",
|
||||||
|
"@esbuild/linux-loong64": "0.27.4",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.4",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.4",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.4",
|
||||||
|
"@esbuild/linux-s390x": "0.27.4",
|
||||||
|
"@esbuild/linux-x64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.4",
|
||||||
|
"@esbuild/sunos-x64": "0.27.4",
|
||||||
|
"@esbuild/win32-arm64": "0.27.4",
|
||||||
|
"@esbuild/win32-ia32": "0.27.4",
|
||||||
|
"@esbuild/win32-x64": "0.27.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -489,6 +1088,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -535,6 +1149,19 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.13.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||||
|
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -853,6 +1480,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -1051,6 +1688,26 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@@ -1064,6 +1721,27 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Minimal Express API for Zui (agent, health; no DB)",
|
"description": "Minimal Express API for Zui (agent, health; no DB)",
|
||||||
"main": "src/index.js",
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.ts",
|
||||||
"dev": "node --watch src/index.js"
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -16,5 +18,12 @@
|
|||||||
"@ai-sdk/openai": "^1.0.0",
|
"@ai-sdk/openai": "^1.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0"
|
"express": "^4.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"tsx": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* Minimal Express API: /api/agent, /health.
|
|
||||||
* No DB, no auth. CORS allowed for frontend. Production-ready env (PORT, CORS_ORIGIN).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const express = require('express')
|
|
||||||
const cors = require('cors')
|
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 8080
|
|
||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:3000'
|
|
||||||
|
|
||||||
const app = express()
|
|
||||||
|
|
||||||
app.use(cors({ origin: CORS_ORIGIN }))
|
|
||||||
app.use(express.json())
|
|
||||||
|
|
||||||
/** Build OpenAI client and full prompt from request body. Returns { openai, modelId, fullPrompt } or { error }. */
|
|
||||||
function buildAgentRequest(body) {
|
|
||||||
const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}
|
|
||||||
const reasoningEnabled = Boolean(reasoning)
|
|
||||||
let baseURL = process.env.AI_BASE_URL?.trim() || null
|
|
||||||
let apiKey = process.env.OPENAI_API_KEY?.trim() || null
|
|
||||||
let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
|
|
||||||
if (conn && typeof conn === 'object') {
|
|
||||||
const c = conn
|
|
||||||
const provider = c.provider === 'openai' ? 'openai' : 'local'
|
|
||||||
if (provider === 'local') {
|
|
||||||
baseURL = (typeof c.baseURL === 'string' && c.baseURL.trim()) ? c.baseURL.trim() : baseURL
|
|
||||||
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : (apiKey || 'lm-studio')
|
|
||||||
} else {
|
|
||||||
baseURL = null
|
|
||||||
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : apiKey
|
|
||||||
}
|
|
||||||
if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
|
|
||||||
}
|
|
||||||
if (!baseURL && !apiKey) {
|
|
||||||
return { error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.' }
|
|
||||||
}
|
|
||||||
const basePrompt = [
|
|
||||||
typeof prompt === 'string' ? prompt : 'No prompt provided.',
|
|
||||||
context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
|
|
||||||
Array.isArray(contextNodes) && contextNodes.length > 0
|
|
||||||
? `\n\nContext from connected nodes:\n${contextNodes.map((n) => (n.content != null ? n.content : `${n.id}: (no content)`)).join('\n\n')}`
|
|
||||||
: '',
|
|
||||||
].join('')
|
|
||||||
const fullPrompt = reasoningEnabled
|
|
||||||
? basePrompt + '\n\nRespond in exactly two markdown sections. First: "## Reasoning" with your step-by-step reasoning. Then: "## Output" with only the final answer. No preamble.'
|
|
||||||
: basePrompt + '\n\nRespond with structured markdown only. No preamble.'
|
|
||||||
const { createOpenAI } = require('@ai-sdk/openai')
|
|
||||||
const openai = createOpenAI({
|
|
||||||
apiKey: apiKey || 'lm-studio',
|
|
||||||
...(baseURL && { baseURL, compatibility: 'compatible' }),
|
|
||||||
})
|
|
||||||
return { openai, modelId, fullPrompt }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/agent — run AI agent; body: { prompt, context?, contextNodes?, connection? }; returns { markdown }. */
|
|
||||||
app.post('/api/agent', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const built = buildAgentRequest(req.body)
|
|
||||||
if (built.error) return res.status(503).json({ error: built.error })
|
|
||||||
const { generateText } = await import('ai')
|
|
||||||
const result = await generateText({
|
|
||||||
model: built.openai(built.modelId),
|
|
||||||
prompt: built.fullPrompt,
|
|
||||||
})
|
|
||||||
const markdown = result?.text ?? ''
|
|
||||||
res.json({ markdown })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Agent error:', err)
|
|
||||||
res.status(500).json({ error: err?.message ?? 'Agent request failed' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/** POST /api/agent/stream — same as /api/agent but streams plain text (markdown) chunks. */
|
|
||||||
app.post('/api/agent/stream', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const built = buildAgentRequest(req.body)
|
|
||||||
if (built.error) return res.status(503).json({ error: built.error })
|
|
||||||
const { streamText } = await import('ai')
|
|
||||||
const result = streamText({
|
|
||||||
model: built.openai(built.modelId),
|
|
||||||
prompt: built.fullPrompt,
|
|
||||||
})
|
|
||||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
||||||
res.setHeader('Transfer-Encoding', 'chunked')
|
|
||||||
result.pipeTextStreamToResponse(res)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Agent stream error:', err)
|
|
||||||
res.status(500).json({ error: err?.message ?? 'Agent request failed' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Health check for Docker / orchestration */
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.status(200).json({ ok: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
|
||||||
console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`)
|
|
||||||
})
|
|
||||||
102
backend/src/index.ts
Normal file
102
backend/src/index.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Backend API: Layered architecture for scalability and maintainability.
|
||||||
|
*
|
||||||
|
* ## Architecture Layers
|
||||||
|
*
|
||||||
|
* ### Controllers (routes/)
|
||||||
|
* - Handle HTTP requests and responses
|
||||||
|
* - Validate input and format output
|
||||||
|
* - Call services for business logic
|
||||||
|
*
|
||||||
|
* ### Services (services/)
|
||||||
|
* - Implement business logic
|
||||||
|
* - Coordinate between controllers and repositories
|
||||||
|
* - Handle validation and transformation
|
||||||
|
*
|
||||||
|
* ### Repositories (repositories/)
|
||||||
|
* - Data access layer
|
||||||
|
* - Interact with databases or external APIs
|
||||||
|
* - Return domain models
|
||||||
|
*
|
||||||
|
* ### Models (models/)
|
||||||
|
* - Data structures and types
|
||||||
|
* - Validation schemas
|
||||||
|
*
|
||||||
|
* ### Middleware (middleware/)
|
||||||
|
* - Request validation
|
||||||
|
* - Authentication/authorization
|
||||||
|
* - Rate limiting
|
||||||
|
* - Error handling
|
||||||
|
*
|
||||||
|
* ## Adding New Endpoints
|
||||||
|
*
|
||||||
|
* 1. Define the model in `models/`
|
||||||
|
* 2. Implement repository logic in `repositories/`
|
||||||
|
* 3. Implement service logic in `services/`
|
||||||
|
* 4. Create controller in `routes/`
|
||||||
|
* 5. Register route in `index.ts`
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT) || 8080
|
||||||
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:3000'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Express App Setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.use(cors({ origin: CORS_ORIGIN }))
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent Routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { handleAgentRequest, handleAgentStreamRequest } from './routes/agentRoutes.js'
|
||||||
|
|
||||||
|
/** POST /api/agent - Run AI agent */
|
||||||
|
app.post('/api/agent', handleAgentRequest)
|
||||||
|
|
||||||
|
/** POST /api/agent/stream - Stream AI agent response */
|
||||||
|
app.post('/api/agent/stream', handleAgentStreamRequest)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Health Check Endpoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** GET /health - Health check for Docker / orchestration */
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.status(200).json({ ok: true, timestamp: Date.now() })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error Handling Middleware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Global error handler for consistent error responses */
|
||||||
|
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
console.error('Error:', err)
|
||||||
|
res.status(500).json({ error: err.message ?? 'Internal server error' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Start Server
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export for testing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default app
|
||||||
88
backend/src/middleware/rateLimiter.ts
Normal file
88
backend/src/middleware/rateLimiter.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Middleware: Rate limiting for API endpoints.
|
||||||
|
*
|
||||||
|
* This module provides rate limiting middleware to protect the API
|
||||||
|
* from abuse and ensure fair usage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit configuration
|
||||||
|
*/
|
||||||
|
export type RateLimitConfig = {
|
||||||
|
/** Maximum number of requests allowed in the window */
|
||||||
|
max: number
|
||||||
|
/** Window size in milliseconds */
|
||||||
|
windowMs: number
|
||||||
|
/** Error message to return when rate limited */
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default rate limit configuration
|
||||||
|
*/
|
||||||
|
export const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig = {
|
||||||
|
max: 100,
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
message: 'Too many requests, please try again later.',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory store for rate limiting
|
||||||
|
*/
|
||||||
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiting middleware
|
||||||
|
*
|
||||||
|
* @param config - Rate limit configuration
|
||||||
|
* @returns Express middleware function
|
||||||
|
*/
|
||||||
|
export function rateLimit(config: RateLimitConfig = DEFAULT_RATE_LIMIT_CONFIG) {
|
||||||
|
return (req: { ip?: string; socket?: { remoteAddress?: string } }, res: { status: (code: number) => { json: (body: { error: string }) => void }, locals: Record<string, unknown> }, next: () => void): void => {
|
||||||
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown'
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
let entry = rateLimitStore.get(ip)
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetTime) {
|
||||||
|
// Reset the counter for a new window
|
||||||
|
entry = { count: 1, resetTime: now + config.windowMs }
|
||||||
|
rateLimitStore.set(ip, entry)
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
|
||||||
|
if (entry.count > config.max) {
|
||||||
|
res.status(429).json({ error: config.message ?? 'Too many requests' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired entries from the rate limit store
|
||||||
|
*/
|
||||||
|
export function cleanupRateLimitStore(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [ip, entry] of rateLimitStore.entries()) {
|
||||||
|
if (now > entry.resetTime) {
|
||||||
|
rateLimitStore.delete(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit status for an IP
|
||||||
|
*/
|
||||||
|
export function getRateLimitStatus(ip: string): { remaining: number; resetTime: number } | null {
|
||||||
|
const entry = rateLimitStore.get(ip)
|
||||||
|
if (!entry) return null
|
||||||
|
return {
|
||||||
|
remaining: Math.max(0, DEFAULT_RATE_LIMIT_CONFIG.max - entry.count),
|
||||||
|
resetTime: entry.resetTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
63
backend/src/models/index.ts
Normal file
63
backend/src/models/index.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Models: Data structures and validation schemas.
|
||||||
|
*
|
||||||
|
* This module defines the domain models used throughout the application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent request payload
|
||||||
|
*/
|
||||||
|
export type AgentRequest = {
|
||||||
|
prompt: string
|
||||||
|
context?: string
|
||||||
|
contextNodes?: AgentContextNode[]
|
||||||
|
connection?: AgentConnection
|
||||||
|
reasoning?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context node for agent requests
|
||||||
|
*/
|
||||||
|
export type AgentContextNode = {
|
||||||
|
id: string
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI connection configuration
|
||||||
|
*/
|
||||||
|
export type AgentConnection = {
|
||||||
|
provider: 'openai' | 'local'
|
||||||
|
baseURL?: string
|
||||||
|
apiKey?: string
|
||||||
|
model?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent response
|
||||||
|
*/
|
||||||
|
export type AgentResponse = {
|
||||||
|
markdown: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent stream response
|
||||||
|
*/
|
||||||
|
export type AgentStreamResponse = {
|
||||||
|
markdown: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check response
|
||||||
|
*/
|
||||||
|
export type HealthResponse = {
|
||||||
|
ok: boolean
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error response
|
||||||
|
*/
|
||||||
|
export type ErrorResponse = {
|
||||||
|
error: string
|
||||||
|
}
|
||||||
103
backend/src/repositories/cacheRepository.ts
Normal file
103
backend/src/repositories/cacheRepository.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Repositories: Data access layer.
|
||||||
|
*
|
||||||
|
* This module handles data persistence and retrieval.
|
||||||
|
* It abstracts the underlying storage mechanism.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache entry for expensive computations
|
||||||
|
*/
|
||||||
|
export type CacheEntry<T> = {
|
||||||
|
/** Unique key for the cache entry */
|
||||||
|
key: string
|
||||||
|
/** Cached data */
|
||||||
|
data: T
|
||||||
|
/** Timestamp (ms) when this entry was created */
|
||||||
|
createdAt: number
|
||||||
|
/** Time-to-live in milliseconds */
|
||||||
|
ttl?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory cache implementation
|
||||||
|
*/
|
||||||
|
export class InMemoryCache {
|
||||||
|
private cache = new Map<string, CacheEntry<unknown>>()
|
||||||
|
private defaultTtl = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from the cache
|
||||||
|
*
|
||||||
|
* @param key - Cache key
|
||||||
|
* @returns Cached value or null if not found or expired
|
||||||
|
*/
|
||||||
|
get<T>(key: string): T | null {
|
||||||
|
const entry = this.cache.get(key) as CacheEntry<T> | undefined
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
// Check if entry has expired
|
||||||
|
if (entry.ttl && Date.now() - entry.createdAt > entry.ttl) {
|
||||||
|
this.cache.delete(key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in the cache
|
||||||
|
*
|
||||||
|
* @param key - Cache key
|
||||||
|
* @param data - Data to cache
|
||||||
|
* @param ttl - Optional time-to-live in milliseconds (uses default if not specified)
|
||||||
|
*/
|
||||||
|
set<T>(key: string, data: T, ttl?: number): void {
|
||||||
|
this.cache.set(key, {
|
||||||
|
key,
|
||||||
|
data,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
ttl: ttl ?? this.defaultTtl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a value from the cache
|
||||||
|
*
|
||||||
|
* @param key - Cache key
|
||||||
|
*/
|
||||||
|
delete(key: string): void {
|
||||||
|
this.cache.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all entries from the cache
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired entries
|
||||||
|
*/
|
||||||
|
cleanup(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, entry] of this.cache.entries()) {
|
||||||
|
if (entry.ttl && now - entry.createdAt > entry.ttl) {
|
||||||
|
this.cache.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of entries in the cache
|
||||||
|
*/
|
||||||
|
size(): number {
|
||||||
|
return this.cache.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global cache instance
|
||||||
|
*/
|
||||||
|
export const cache = new InMemoryCache()
|
||||||
72
backend/src/routes/agentRoutes.ts
Normal file
72
backend/src/routes/agentRoutes.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Routes: HTTP request handlers.
|
||||||
|
*
|
||||||
|
* This module contains the route handlers for the API.
|
||||||
|
* Routes validate input and format output, then call services for business logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentRequest, AgentResponse, ErrorResponse } from '../models'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/agent - Run AI agent
|
||||||
|
*
|
||||||
|
* Request body: { prompt, context?, contextNodes?, connection? }
|
||||||
|
* Response: { markdown }
|
||||||
|
*/
|
||||||
|
export async function handleAgentRequest(req: any, res: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
const body = req.body
|
||||||
|
const { buildAgentRequest } = await import('../services/agentService')
|
||||||
|
|
||||||
|
const built = buildAgentRequest(body)
|
||||||
|
if ('error' in built) {
|
||||||
|
res.status(503).json({ error: built.error } as ErrorResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { generateText } = await import('ai')
|
||||||
|
const result = await generateText({
|
||||||
|
model: built.openai as any,
|
||||||
|
prompt: built.fullPrompt,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { processAgentResponse } = await import('../services/agentService')
|
||||||
|
const response = processAgentResponse(result)
|
||||||
|
res.json(response)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Agent error:', err)
|
||||||
|
res.status(500).json({ error: (err as Error).message ?? 'Agent request failed' } as ErrorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/agent/stream - Stream AI agent response
|
||||||
|
*
|
||||||
|
* Request body: { prompt, context?, contextNodes?, connection? }
|
||||||
|
* Response: plain text (markdown) chunks
|
||||||
|
*/
|
||||||
|
export async function handleAgentStreamRequest(req: any, res: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
const body = req.body
|
||||||
|
const { buildAgentRequest } = await import('../services/agentService')
|
||||||
|
|
||||||
|
const built = buildAgentRequest(body)
|
||||||
|
if ('error' in built) {
|
||||||
|
res.status(503).json({ error: built.error } as ErrorResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { streamText } = await import('ai')
|
||||||
|
const result = streamText({
|
||||||
|
model: built.openai as any,
|
||||||
|
prompt: built.fullPrompt,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
res.setHeader('Transfer-Encoding', 'chunked')
|
||||||
|
result.pipeTextStreamToResponse(res)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Agent stream error:', err)
|
||||||
|
res.status(500).json({ error: (err as Error).message ?? 'Agent request failed' } as ErrorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
backend/src/services/agentService.ts
Normal file
65
backend/src/services/agentService.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Services: Business logic layer.
|
||||||
|
*
|
||||||
|
* This module contains the business logic for the application.
|
||||||
|
* Services coordinate between controllers and repositories.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentRequest, AgentResponse, AgentConnection } from '../models'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build OpenAI client and full prompt from request body.
|
||||||
|
* Returns { openai, modelId, fullPrompt } or { error }.
|
||||||
|
*/
|
||||||
|
export function buildAgentRequest(body: AgentRequest): { openai: unknown; modelId: string; fullPrompt: string } | { error: string } {
|
||||||
|
const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}
|
||||||
|
const reasoningEnabled = Boolean(reasoning)
|
||||||
|
let baseURL = process.env.AI_BASE_URL?.trim() || null
|
||||||
|
let apiKey = process.env.OPENAI_API_KEY?.trim() || null
|
||||||
|
let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
|
||||||
|
|
||||||
|
if (conn && typeof conn === 'object') {
|
||||||
|
const c = conn as AgentConnection
|
||||||
|
const provider = c.provider === 'openai' ? 'openai' : 'local'
|
||||||
|
if (provider === 'local') {
|
||||||
|
baseURL = (typeof c.baseURL === 'string' && c.baseURL.trim()) ? c.baseURL.trim() : baseURL
|
||||||
|
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : (apiKey || 'lm-studio')
|
||||||
|
} else {
|
||||||
|
baseURL = null
|
||||||
|
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : apiKey
|
||||||
|
}
|
||||||
|
if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseURL && !apiKey) {
|
||||||
|
return { error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePrompt = [
|
||||||
|
typeof prompt === 'string' ? prompt : 'No prompt provided.',
|
||||||
|
context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
|
||||||
|
Array.isArray(contextNodes) && contextNodes.length > 0
|
||||||
|
? `\n\nContext from connected nodes:\n${contextNodes.map((n) => (n.content != null ? n.content : `${n.id}: (no content)`)).join('\n\n')}`
|
||||||
|
: '',
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const fullPrompt = reasoningEnabled
|
||||||
|
? basePrompt + '\n\nRespond in exactly two markdown sections. First: "## Reasoning" with your step-by-step reasoning. Then: "## Output" with only the final answer. No preamble.'
|
||||||
|
: basePrompt + '\n\nRespond with structured markdown only. No preamble.'
|
||||||
|
|
||||||
|
const { createOpenAI } = require('@ai-sdk/openai')
|
||||||
|
const openai = createOpenAI({
|
||||||
|
apiKey: apiKey || 'lm-studio',
|
||||||
|
...(baseURL && { baseURL, compatibility: 'compatible' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return { openai, modelId, fullPrompt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process agent response
|
||||||
|
*/
|
||||||
|
export function processAgentResponse(result: { text?: string }): AgentResponse {
|
||||||
|
const markdown = result?.text ?? ''
|
||||||
|
return { markdown }
|
||||||
|
}
|
||||||
20
backend/tsconfig.json
Normal file
20
backend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
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!*
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>React Flow + shadcn Canvas</title>
|
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
|
||||||
</head>
|
<title>ZOË | Kosmos</title>
|
||||||
<body>
|
</head>
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<body>
|
||||||
</body>
|
<div id="root"></div>
|
||||||
</html>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
804
frontend/package-lock.json
generated
804
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,9 @@
|
|||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocknote/core": "^0.47.1",
|
"@blocknote/core": "0.36.1",
|
||||||
"@blocknote/react": "^0.47.1",
|
"@blocknote/react": "0.36.1",
|
||||||
"@blocknote/shadcn": "^0.47.1",
|
"@blocknote/shadcn": "0.36.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-arborist": "^3.4.3",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
|
|||||||
21
frontend/src/app/NotFoundPage.tsx
Normal file
21
frontend/src/app/NotFoundPage.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 404 page for unknown routes. Shown when no route matches.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-background p-8">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Page not found</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The page you’re looking for doesn’t exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="default">
|
||||||
|
<Link to="/recollections">Back to Recollections</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -36,8 +36,8 @@ import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
|||||||
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
||||||
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
|
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
|
||||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
import { useRecollectionActions } from '@/app/recollections/RecollectionActionsContext'
|
import { useRecollectionActions } from '@/app/recollections/layout/RecollectionActionsContext'
|
||||||
import type { StoredGraphState } from '@/app/recollections/recollectionStore'
|
import type { StoredGraphState } from '@/app/recollections/state/recollectionStore'
|
||||||
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||||||
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
||||||
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
|
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
|
||||||
@@ -67,7 +67,7 @@ import {
|
|||||||
} from '@/lib/graph/nodeRegistry'
|
} from '@/lib/graph/nodeRegistry'
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
import { RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
|
|
||||||
const SNAP_GRID: [number, number] = [15, 15]
|
const SNAP_GRID: [number, number] = [15, 15]
|
||||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||||
@@ -76,12 +76,60 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
|||||||
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
||||||
})
|
})
|
||||||
|
|
||||||
function FlowFitViewOnLoad() {
|
function FlowFitViewOnLoad({ disabled }: { disabled?: boolean }) {
|
||||||
const nodesInitialized = useNodesInitialized()
|
const nodesInitialized = useNodesInitialized()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (nodesInitialized) fitView?.({ duration: 200 })
|
if (disabled || !nodesInitialized) return
|
||||||
}, [nodesInitialized, fitView])
|
fitView?.({ duration: 200 })
|
||||||
|
}, [disabled, nodesInitialized, fitView])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function FocusNodeOnLoad({ focusNodeId }: { focusNodeId?: string }) {
|
||||||
|
const nodesInitialized = useNodesInitialized()
|
||||||
|
const { getNodes, setCenter, screenToFlowPosition, project } = useReactFlow()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!focusNodeId || !nodesInitialized) return
|
||||||
|
// Small delay so node DOM and layout are fully ready before centering/zooming.
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const nodes = getNodes()
|
||||||
|
const node = nodes.find((n) => n.id === focusNodeId)
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
const el = document.querySelector(
|
||||||
|
`.react-flow__node[data-id="${focusNodeId}"]`
|
||||||
|
) as HTMLElement | null
|
||||||
|
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const screenCenter = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
||||||
|
const toFlow = screenToFlowPosition ?? project
|
||||||
|
if (toFlow) {
|
||||||
|
const flowCenter = toFlow(screenCenter)
|
||||||
|
setCenter(flowCenter.x, flowCenter.y, { duration: 400, zoom: 1.8 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: center using node position + dimensions from React Flow state.
|
||||||
|
const anyNode = node as Node & {
|
||||||
|
positionAbsolute?: { x: number; y: number }
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
const basePos = anyNode.positionAbsolute ?? anyNode.position ?? { x: 0, y: 0 }
|
||||||
|
const width = anyNode.width ?? 0
|
||||||
|
const height = anyNode.height ?? 0
|
||||||
|
const centerX = basePos.x + width / 2
|
||||||
|
const centerY = basePos.y + height / 2
|
||||||
|
setCenter(centerX, centerY, { duration: 400, zoom: 1.8 })
|
||||||
|
}, 150)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [focusNodeId, nodesInitialized, getNodes, setCenter, screenToFlowPosition, project])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +200,11 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
|
|||||||
export type CanvasPageProps = {
|
export type CanvasPageProps = {
|
||||||
/** Optional recollection id for per-recollection graph loading */
|
/** Optional recollection id for per-recollection graph loading */
|
||||||
recollectionId?: string
|
recollectionId?: string
|
||||||
|
/** Optional node id to focus when the canvas loads (e.g. from Katalogos artifacts). */
|
||||||
|
focusNodeId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { showMinimap } = usePlatform()
|
const { showMinimap } = usePlatform()
|
||||||
const {
|
const {
|
||||||
@@ -688,7 +738,8 @@ export function CanvasPage({ recollectionId }: CanvasPageProps) {
|
|||||||
)}
|
)}
|
||||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||||
<ViewportDisplayProvider>
|
<ViewportDisplayProvider>
|
||||||
<FlowFitViewOnLoad />
|
<FlowFitViewOnLoad disabled={Boolean(focusNodeId)} />
|
||||||
|
<FocusNodeOnLoad focusNodeId={focusNodeId} />
|
||||||
<FlowKeyboardShortcuts />
|
<FlowKeyboardShortcuts />
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
||||||
import { loadGraphFromStorage } from '@/app/recollections/recollectionGraphStorage'
|
import { loadGraphFromStorage } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
|
|
||||||
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
||||||
export function backfillEdgeTargetTypes(
|
export function backfillEdgeTargetTypes(
|
||||||
|
|||||||
@@ -26,5 +26,19 @@ export {
|
|||||||
selectRenamingNodeId,
|
selectRenamingNodeId,
|
||||||
selectFullscreenNodeId,
|
selectFullscreenNodeId,
|
||||||
selectConnectionFrom,
|
selectConnectionFrom,
|
||||||
|
// Fine-grained selectors
|
||||||
|
selectNodeById,
|
||||||
|
selectNodesById,
|
||||||
|
selectNodesByType,
|
||||||
|
selectNodeIdsByType,
|
||||||
|
selectEdgesBySource,
|
||||||
|
selectEdgesByTarget,
|
||||||
|
selectEdgesForNode,
|
||||||
|
selectNodeCount,
|
||||||
|
selectEdgeCount,
|
||||||
|
selectHasNode,
|
||||||
|
selectHasEdge,
|
||||||
|
// SVG detection
|
||||||
|
isSvgContent,
|
||||||
} from './canvasStore.selectors'
|
} from './canvasStore.selectors'
|
||||||
export type { ConnectionPathRole } from './canvasStore.selectors'
|
export type { ConnectionPathRole } from './canvasStore.selectors'
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ import {
|
|||||||
} from '@/lib/graph/graphologyPath'
|
} from '@/lib/graph/graphologyPath'
|
||||||
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
|
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
|
||||||
import type { CanvasStore } from './canvasStore.types'
|
import type { CanvasStore } from './canvasStore.types'
|
||||||
import type { AppNode } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
|
|
||||||
export type ConnectionPathRole = 'trigger' | 'on-path' | null
|
export type ConnectionPathRole = 'trigger' | 'on-path' | null
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SVG Detection - Centralized logic for detecting SVG content
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Check if a string contains SVG content. */
|
||||||
|
export function isSvgContent(content: string | null | undefined): boolean {
|
||||||
|
return Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Raw slices
|
// Raw slices
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -38,6 +47,65 @@ export function selectEdges(state: CanvasStore) {
|
|||||||
return state.graph.edges
|
return state.graph.edges
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fine-grained selectors for optimal re-rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Get a specific node by ID. */
|
||||||
|
export function selectNodeById(state: CanvasStore, nodeId: string): AppNode | undefined {
|
||||||
|
return state.graph.nodes.find((n) => n.id === nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get multiple nodes by IDs. */
|
||||||
|
export function selectNodesById(state: CanvasStore, nodeIds: string[]): AppNode[] {
|
||||||
|
return state.graph.nodes.filter((n) => nodeIds.includes(n.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get nodes by type. */
|
||||||
|
export function selectNodesByType(state: CanvasStore, nodeType: string): AppNode[] {
|
||||||
|
return state.graph.nodes.filter((n) => n.type === nodeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get node IDs by type. */
|
||||||
|
export function selectNodeIdsByType(state: CanvasStore, nodeType: string): string[] {
|
||||||
|
return state.graph.nodes.filter((n) => n.type === nodeType).map((n) => n.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get edges by source node ID. */
|
||||||
|
export function selectEdgesBySource(state: CanvasStore, sourceId: string): AppEdge[] {
|
||||||
|
return state.graph.edges.filter((e) => e.source === sourceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get edges by target node ID. */
|
||||||
|
export function selectEdgesByTarget(state: CanvasStore, targetId: string): AppEdge[] {
|
||||||
|
return state.graph.edges.filter((e) => e.target === targetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all edges connected to a node (source or target). */
|
||||||
|
export function selectEdgesForNode(state: CanvasStore, nodeId: string): AppEdge[] {
|
||||||
|
return state.graph.edges.filter((e) => e.source === nodeId || e.target === nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get node count. */
|
||||||
|
export function selectNodeCount(state: CanvasStore): number {
|
||||||
|
return state.graph.nodes.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get edge count. */
|
||||||
|
export function selectEdgeCount(state: CanvasStore): number {
|
||||||
|
return state.graph.edges.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a node exists. */
|
||||||
|
export function selectHasNode(state: CanvasStore, nodeId: string): boolean {
|
||||||
|
return state.graph.nodes.some((n) => n.id === nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if an edge exists. */
|
||||||
|
export function selectHasEdge(state: CanvasStore, source: string, target: string): boolean {
|
||||||
|
return state.graph.edges.some((e) => e.source === source && e.target === target)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Derived path (Sets) – depend on graph.edges + path primitive arrays
|
// Derived path (Sets) – depend on graph.edges + path primitive arrays
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
* with initial graph from recollection storage (or example). Save is explicit via save().
|
* 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 { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||||
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
||||||
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
|
|
||||||
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
|
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
|
||||||
@@ -59,5 +59,16 @@ export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGra
|
|||||||
}, SAVING_DISPLAY_MS)
|
}, SAVING_DISPLAY_MS)
|
||||||
}, [recollectionId])
|
}, [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 }
|
return { ...result, save, saveStatus }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||||
import type { Recollection } from './types'
|
import type { Recollection } from './types'
|
||||||
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/recollectionGraphStorage'
|
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
|
|
||||||
const STORAGE_KEY = 'zui_platform_recollections'
|
const STORAGE_KEY = 'zui_platform_recollections'
|
||||||
const ORDER_STORAGE_KEY = 'zui_platform_recollection_order'
|
const ORDER_STORAGE_KEY = 'zui_platform_recollection_order'
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ function KosmosLayoutInner() {
|
|||||||
if (recollectionId) recordRecollectionAccess(recollectionId)
|
if (recollectionId) recordRecollectionAccess(recollectionId)
|
||||||
}, [recollectionId, recordRecollectionAccess])
|
}, [recollectionId, recordRecollectionAccess])
|
||||||
|
|
||||||
|
const showSidebar = !recollectionId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||||
<KosmosSidebar />
|
{showSidebar && <KosmosSidebar />}
|
||||||
<div className="relative flex h-dvh min-h-0 w-full flex-1 flex-col overflow-hidden bg-background">
|
<div className="relative flex h-dvh min-h-0 w-full flex-1 flex-col overflow-hidden bg-background">
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export function KosmosSidebar() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const handleSelectRecollection = useCallback((id: string) => navigate(`/recollections/${id}`), [navigate])
|
|
||||||
|
|
||||||
const handleCreateRecollection = useCallback(
|
const handleCreateRecollection = useCallback(
|
||||||
(recollection: Parameters<typeof createRecollection>[0]) => {
|
(recollection: Parameters<typeof createRecollection>[0]) => {
|
||||||
@@ -99,7 +98,7 @@ export function KosmosSidebar() {
|
|||||||
) : (
|
) : (
|
||||||
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
|
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
|
||||||
<Link to="/recollections">
|
<Link to="/recollections">
|
||||||
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground dark:bg-white dark:text-black">
|
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg dark:bg-white dark:text-black">
|
||||||
<span
|
<span
|
||||||
className="size-6 shrink-0 rounded-[2px] opacity-90"
|
className="size-6 shrink-0 rounded-[2px] opacity-90"
|
||||||
style={{
|
style={{
|
||||||
@@ -149,13 +148,11 @@ export function KosmosSidebar() {
|
|||||||
const isActive = selectedRecollectionId === recollection.id
|
const isActive = selectedRecollectionId === recollection.id
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={recollection.id}>
|
<SidebarMenuItem key={recollection.id}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton asChild tooltip={recollection.name} isActive={isActive}>
|
||||||
tooltip={recollection.name}
|
<Link to={`/recollections/${recollection.id}`}>
|
||||||
isActive={isActive}
|
<Icon className="size-4" />
|
||||||
onClick={() => handleSelectRecollection(recollection.id)}
|
<span>{recollection.name}</span>
|
||||||
>
|
</Link>
|
||||||
<Icon className="size-4" />
|
|
||||||
<span>{recollection.name}</span>
|
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
/**
|
|
||||||
* Recollection view switcher: Logos or Flux based on pathname.
|
|
||||||
* Single container, conditional render — only the active view is mounted (no CSS flip).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
import { useLocation, useParams } from 'react-router-dom'
|
|
||||||
import { LogosPage } from './logos/LogosPage'
|
|
||||||
import { FluxRoute } from './flux/FluxRoute'
|
|
||||||
|
|
||||||
export function FlippingCardView() {
|
|
||||||
const { pathname } = useLocation()
|
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
|
||||||
const isFlux = pathname.endsWith('/flux')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
||||||
{isFlux ? (
|
|
||||||
recollectionId ? (
|
|
||||||
<FluxRoute />
|
|
||||||
) : null
|
|
||||||
) : (
|
|
||||||
<LogosPage />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Layout for a single recollection: shared menubar and flipping card (Logos front / Flux back).
|
* Layout for a single recollection: shared menubar and nested routes for Logos / Katalogos / Flux.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Outlet, useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { RecollectionMenubarProvider } from './RecollectionMenubarContext'
|
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
|
||||||
import { RecollectionActionsProvider } from './RecollectionActionsContext'
|
import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext'
|
||||||
import { RecollectionTitleContent } from './RecollectionTitleContent'
|
import { RecollectionMenubar } from './layout/RecollectionMenubar'
|
||||||
import { RecollectionMenubar } from './RecollectionMenubar'
|
import { RecollectionSidebar } from './layout/RecollectionSidebar'
|
||||||
import { RecollectionViewSwitcher } from './RecollectionViewSwitcher'
|
|
||||||
import { FlippingCardView } from './FlippingCardView'
|
|
||||||
|
|
||||||
export function RecollectionLayout() {
|
export function RecollectionLayout() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { recollections, recordRecollectionAccess } = usePlatform()
|
const { recollections, recordRecollectionAccess } = usePlatform()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,30 +22,44 @@ export function RecollectionLayout() {
|
|||||||
|
|
||||||
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollectionId) return
|
||||||
|
if (recollection) return
|
||||||
|
toast.error('Recollection not found')
|
||||||
|
navigate('/recollections', { replace: true })
|
||||||
|
}, [recollectionId, recollection, navigate])
|
||||||
|
|
||||||
|
// Keyboard shortcut: Escape to go back to recollections list
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollection) return
|
||||||
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key !== 'Escape') return
|
||||||
|
if ((ev.target as HTMLElement)?.closest('input, textarea, [contenteditable="true"]')) return
|
||||||
|
ev.preventDefault()
|
||||||
|
navigate('/recollections')
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
|
}, [recollection, navigate])
|
||||||
|
|
||||||
if (!recollectionId) return null
|
if (!recollectionId) return null
|
||||||
if (!recollection) {
|
if (!recollection) {
|
||||||
return (
|
return null
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
|
||||||
<p className="text-sm text-muted-foreground">Recollection not found.</p>
|
|
||||||
<Link to="/recollections" className="text-sm text-primary hover:underline">
|
|
||||||
Back to recollections
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RecollectionMenubarProvider>
|
<RecollectionActionsProvider>
|
||||||
<RecollectionActionsProvider>
|
<RecollectionSidebarProvider>
|
||||||
<RecollectionTitleContent />
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
|
||||||
<RecollectionMenubar />
|
<RecollectionMenubar />
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
|
||||||
<FlippingCardView />
|
<RecollectionSidebar />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<RecollectionViewSwitcher />
|
|
||||||
</div>
|
</div>
|
||||||
</RecollectionActionsProvider>
|
</RecollectionSidebarProvider>
|
||||||
</RecollectionMenubarProvider>
|
</RecollectionActionsProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Recollection menubar: Back | title + save status | registered menus (middle).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
import { Link, useParams } from 'react-router-dom'
|
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
|
||||||
import { ArrowLeft } from 'lucide-react'
|
|
||||||
import { useRecollectionMenubar } from './RecollectionMenubarContext'
|
|
||||||
import { RecollectionEditViewMenus } from './RecollectionEditViewMenus'
|
|
||||||
|
|
||||||
export function RecollectionMenubar() {
|
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
|
||||||
const { recollections } = usePlatform()
|
|
||||||
const { titleContent } = useRecollectionMenubar()
|
|
||||||
|
|
||||||
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
|
||||||
const defaultTitle = recollection ? (
|
|
||||||
<span className="truncate text-sm font-medium text-muted-foreground">{recollection.name}</span>
|
|
||||||
) : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-9 w-full shrink-0 items-center gap-2 overflow-visible border-b border-border/40 bg-background px-2">
|
|
||||||
<Link
|
|
||||||
to="/recollections"
|
|
||||||
aria-label="Back to recollections"
|
|
||||||
className="flex shrink-0 items-center rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
</Link>
|
|
||||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
|
||||||
{titleContent ?? defaultTitle}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-visible">
|
|
||||||
<RecollectionEditViewMenus />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Context for the recollection menubar:
|
|
||||||
* - Title (next to back): optional title + status, e.g. recollection name and save state.
|
|
||||||
* - Middle: shared Edit/View menus (RecollectionEditViewMenus), no longer registrable.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useState } from 'react'
|
|
||||||
|
|
||||||
export type RecollectionMenubarContextValue = {
|
|
||||||
/** Content next to the back button (e.g. title + save status, with optional dropdown). Null = use default recollection name. */
|
|
||||||
titleContent: React.ReactNode
|
|
||||||
setTitleContent: (content: React.ReactNode) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const RecollectionMenubarContext = createContext<RecollectionMenubarContextValue | null>(null)
|
|
||||||
|
|
||||||
export function RecollectionMenubarProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [titleContent, setTitleContentState] = useState<React.ReactNode>(null)
|
|
||||||
const setTitleContent = useCallback((content: React.ReactNode) => {
|
|
||||||
setTitleContentState(() => content)
|
|
||||||
}, [])
|
|
||||||
const value: RecollectionMenubarContextValue = React.useMemo(
|
|
||||||
() => ({ titleContent, setTitleContent }),
|
|
||||||
[titleContent, setTitleContent]
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<RecollectionMenubarContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</RecollectionMenubarContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRecollectionMenubar(): RecollectionMenubarContextValue {
|
|
||||||
const ctx = useContext(RecollectionMenubarContext)
|
|
||||||
if (!ctx) throw new Error('useRecollectionMenubar must be used within RecollectionMenubarProvider')
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared title + save status block (same for Logos and Flux). Gets save status and Save from
|
|
||||||
* the active slot (RecollectionActionsContext); Import/Export from layout-level handlers.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { useParams } from 'react-router-dom'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
|
||||||
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
|
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
|
||||||
import { useRecollectionMenubar } from './RecollectionMenubarContext'
|
|
||||||
import { useRecollectionActions } from './RecollectionActionsContext'
|
|
||||||
import { CheckCircle2, ChevronDown, CircleDot, Download, FolderOpen, Loader2, Pencil, Save } from 'lucide-react'
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
||||||
|
|
||||||
const SAVE_KEYS = { key: 's', shiftKey: false }
|
|
||||||
|
|
||||||
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
|
||||||
const mod = ev.ctrlKey || ev.metaKey
|
|
||||||
return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Registers the shared title + save dropdown as titleContent. Renders nothing. */
|
|
||||||
export function RecollectionTitleContent() {
|
|
||||||
const { setTitleContent } = useRecollectionMenubar()
|
|
||||||
const { activeSlot, onImport, onExport } = useRecollectionActions()
|
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
|
||||||
const { recollections, renameRecollection } = usePlatform()
|
|
||||||
const recollectionName = useMemo(
|
|
||||||
() => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? null : null),
|
|
||||||
[recollectionId, recollections]
|
|
||||||
)
|
|
||||||
|
|
||||||
const [renameModalOpen, setRenameModalOpen] = useState(false)
|
|
||||||
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
|
|
||||||
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const prevSaveStatusRef = useRef<string>('saved')
|
|
||||||
const saveStatus = activeSlot?.saveStatus ?? 'saved'
|
|
||||||
const onSave = activeSlot?.onSave
|
|
||||||
const canSave = activeSlot?.canSave ?? false
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (saveStatus === 'unsaved') {
|
|
||||||
setShowSavedBriefly(false)
|
|
||||||
if (savedBrieflyTimerRef.current) {
|
|
||||||
clearTimeout(savedBrieflyTimerRef.current)
|
|
||||||
savedBrieflyTimerRef.current = null
|
|
||||||
}
|
|
||||||
} else if (prevSaveStatusRef.current === 'saving' && saveStatus === 'saved') {
|
|
||||||
setShowSavedBriefly(true)
|
|
||||||
if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current)
|
|
||||||
savedBrieflyTimerRef.current = setTimeout(() => {
|
|
||||||
savedBrieflyTimerRef.current = null
|
|
||||||
setShowSavedBriefly(false)
|
|
||||||
}, 2500)
|
|
||||||
}
|
|
||||||
prevSaveStatusRef.current = saveStatus
|
|
||||||
}, [saveStatus])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKeyDown = (ev: KeyboardEvent) => {
|
|
||||||
if (matchKey(ev, SAVE_KEYS) && onSave && canSave) {
|
|
||||||
ev.preventDefault()
|
|
||||||
ev.stopPropagation()
|
|
||||||
onSave()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKeyDown, true)
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
|
||||||
}, [onSave, canSave])
|
|
||||||
|
|
||||||
const trigger = useMemo(
|
|
||||||
() => (
|
|
||||||
<span className="flex items-center gap-1.5 truncate">
|
|
||||||
<span className="truncate text-sm font-medium font-serif max-w-[180px]">
|
|
||||||
{recollectionName ?? 'Untitled'}
|
|
||||||
</span>
|
|
||||||
<TooltipProvider delayDuration={300}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span
|
|
||||||
className="shrink-0 flex items-center text-muted-foreground cursor-default"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label={
|
|
||||||
saveStatus === 'saving' ? 'Saving' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{saveStatus === 'saving' && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
|
||||||
{saveStatus === 'unsaved' && <CircleDot className="size-3.5" aria-hidden />}
|
|
||||||
{saveStatus !== 'unsaved' && (saveStatus === 'saved' || showSavedBriefly) && (
|
|
||||||
<CheckCircle2 className="size-3.5 text-muted-foreground/70" aria-hidden />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
[recollectionName, saveStatus, showSavedBriefly]
|
|
||||||
)
|
|
||||||
|
|
||||||
const dropdownContent = useMemo(() => (
|
|
||||||
<>
|
|
||||||
{recollectionId && (
|
|
||||||
<>
|
|
||||||
{onSave != null && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={!canSave}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
Save
|
|
||||||
<span className="ml-auto pl-4"><KbdGroup><Kbd>⌘S</Kbd></KbdGroup></span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem onClick={() => setRenameModalOpen(true)} className="gap-2">
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
Rename
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem onClick={onImport} className="gap-2">
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
Import…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={onExport} className="gap-2">
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Export…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
), [recollectionId, onSave, canSave, onImport, onExport])
|
|
||||||
|
|
||||||
const titleNode = useMemo(() => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent">
|
|
||||||
{trigger}
|
|
||||||
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuPortal>
|
|
||||||
<DropdownMenuContent align="start" className="z-[200] min-w-[12rem]">
|
|
||||||
{dropdownContent}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</DropdownMenu>
|
|
||||||
), [trigger, dropdownContent])
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
setTitleContent(titleNode)
|
|
||||||
return () => setTitleContent(null)
|
|
||||||
}, [titleNode, setTitleContent])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RenameRecollectionDialog
|
|
||||||
open={renameModalOpen}
|
|
||||||
onOpenChange={setRenameModalOpen}
|
|
||||||
recollectionId={recollectionId ?? ''}
|
|
||||||
initialName={recollectionName ?? ''}
|
|
||||||
recollections={recollections}
|
|
||||||
onRename={(id, newName) => {
|
|
||||||
renameRecollection(id, newName)
|
|
||||||
setRenameModalOpen(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* Elevator-style view switcher: circular button moves between top (Logos) and bottom (Flux).
|
|
||||||
* Click toggles; shortcut and tooltip on hover.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useCallback, useEffect } from 'react'
|
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
|
||||||
import { FluxIcon, LogosIcon } from '@/lib/icons'
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
||||||
import { Kbd } from '@/components/ui/kbd'
|
|
||||||
|
|
||||||
const VIEW_TOGGLE_KEYS = { key: 'v', shiftKey: true }
|
|
||||||
|
|
||||||
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
|
||||||
const mod = ev.ctrlKey || ev.metaKey
|
|
||||||
return ev.key.toLowerCase() === want.key && !!mod && ev.shiftKey === want.shiftKey
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortcutLabel() {
|
|
||||||
if (typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform)) {
|
|
||||||
return '⌘⇧V'
|
|
||||||
}
|
|
||||||
return 'Ctrl+⇧+V'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecollectionViewSwitcher() {
|
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
|
||||||
const { pathname } = useLocation()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
|
||||||
|
|
||||||
const isFlux = pathname.endsWith('/flux')
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
|
||||||
if (!base) return
|
|
||||||
navigate(isFlux ? `${base}/logos` : `${base}/flux`)
|
|
||||||
}, [base, isFlux, navigate])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKeyDown = (ev: KeyboardEvent) => {
|
|
||||||
if (matchKey(ev, VIEW_TOGGLE_KEYS)) {
|
|
||||||
ev.preventDefault()
|
|
||||||
toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKeyDown, true)
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
|
||||||
}, [toggle])
|
|
||||||
|
|
||||||
if (!base) return null
|
|
||||||
|
|
||||||
const tooltipText = isFlux
|
|
||||||
? `Switch to Logos`
|
|
||||||
: `Switch to Flux`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute left-1/2 top-[18px] z-10 flex -translate-x-1/2 -translate-y-1/2 items-center gap-2">
|
|
||||||
<TooltipProvider delayDuration={300}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggle}
|
|
||||||
aria-label={isFlux ? 'Switch to Logos' : 'Switch to Flux'}
|
|
||||||
className="flex items-center gap-2 rounded-full border border-border/60 bg-background shadow-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<div className="relative h-14 w-8 rounded-full bg-muted/50 p-0.5">
|
|
||||||
<span
|
|
||||||
className="absolute left-1/2 flex h-6 w-6 -translate-x-1/2 items-center justify-center rounded-full border bg-background shadow-sm transition-[top] duration-200 ease-out"
|
|
||||||
style={{ top: isFlux ? 28 : 4 }}
|
|
||||||
>
|
|
||||||
{isFlux ? (
|
|
||||||
<FluxIcon className="size-3.5 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<LogosIcon className="size-3.5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">{tooltipText}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<Kbd className="text-[10px]">{shortcutLabel()}</Kbd>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -63,12 +63,12 @@ import {
|
|||||||
removeGraphFromStorage,
|
removeGraphFromStorage,
|
||||||
RECOLLECTION_FILE_EXT,
|
RECOLLECTION_FILE_EXT,
|
||||||
RECOLLECTION_VERSION,
|
RECOLLECTION_VERSION,
|
||||||
} from './recollectionGraphStorage'
|
} from './state/recollectionGraphStorage'
|
||||||
import { getLogosContent, setLogosContent } from './recollectionStore'
|
import { getLogosContent, setLogosContent } from './state/recollectionStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
|
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
|
||||||
import { RecollectionsPageBackground } from './RecollectionsPageBackground'
|
import { RecollectionsPageBackground } from './layout/RecollectionsPageBackground'
|
||||||
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
|
import { RenameRecollectionDialog } from './layout/RenameRecollectionDialog'
|
||||||
import type { Recollection } from '@/app/kosmos/types'
|
import type { Recollection } from '@/app/kosmos/types'
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
||||||
@@ -260,7 +260,10 @@ function RecollectionActionsMenu({
|
|||||||
|
|
||||||
export type ViewMode = 'table' | 'cards'
|
export type ViewMode = 'table' | 'cards'
|
||||||
|
|
||||||
|
const RECOLLECTIONS_SCROLL_KEY = 'recollections-list-scroll'
|
||||||
|
|
||||||
export function RecollectionsPage() {
|
export function RecollectionsPage() {
|
||||||
|
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
||||||
const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform()
|
const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
@@ -329,6 +332,28 @@ export function RecollectionsPage() {
|
|||||||
}
|
}
|
||||||
}, [duplicateTarget])
|
}, [duplicateTarget])
|
||||||
|
|
||||||
|
// Restore scroll position when returning to the list
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = scrollContainerRef.current
|
||||||
|
if (!el) return
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(RECOLLECTIONS_SCROLL_KEY)
|
||||||
|
if (saved !== null) {
|
||||||
|
const top = parseInt(saved, 10)
|
||||||
|
if (Number.isFinite(top)) requestAnimationFrame(() => { el.scrollTop = top })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(RECOLLECTIONS_SCROLL_KEY, String(el.scrollTop))
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleDeleteOpen = useCallback((recollection: Recollection) => {
|
const handleDeleteOpen = useCallback((recollection: Recollection) => {
|
||||||
setDeleteTarget(recollection)
|
setDeleteTarget(recollection)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -487,7 +512,7 @@ export function RecollectionsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex flex-1 flex-col min-h-0">
|
<div className="relative flex flex-1 flex-col min-h-0">
|
||||||
<RecollectionsPageBackground className="absolute inset-0 pointer-events-none" />
|
<RecollectionsPageBackground className="absolute inset-0 pointer-events-none" />
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
<div ref={scrollContainerRef} className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance font-serif">
|
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance font-serif">
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Flux route: renders the graph canvas (CanvasPage) for the current recollection.
|
* Flux route: renders the graph canvas (CanvasPage) for the current recollection.
|
||||||
|
* Supports optional focusNode query param to center on a specific node.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useRef } from 'react'
|
import React, { useEffect, useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
|
||||||
export function FluxRoute() {
|
export function FluxRoute() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const { updateLastEdited } = usePlatform()
|
const { updateLastEdited } = usePlatform()
|
||||||
const updateLastEditedRef = useRef(updateLastEdited)
|
const updateLastEditedRef = useRef(updateLastEdited)
|
||||||
updateLastEditedRef.current = updateLastEdited
|
updateLastEditedRef.current = updateLastEdited
|
||||||
@@ -17,11 +19,13 @@ export function FluxRoute() {
|
|||||||
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
||||||
}, [recollectionId])
|
}, [recollectionId])
|
||||||
|
|
||||||
|
const focusNodeId = searchParams.get('focusNode') ?? undefined
|
||||||
|
|
||||||
if (!recollectionId) return null
|
if (!recollectionId) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<CanvasPage key={recollectionId} recollectionId={recollectionId} />
|
<CanvasPage key={recollectionId} recollectionId={recollectionId} focusNodeId={focusNodeId} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
127
frontend/src/app/recollections/katalogos/KatalogosPage.tsx
Normal file
127
frontend/src/app/recollections/katalogos/KatalogosPage.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Katalogos page: third view for a recollection.
|
||||||
|
* Shows live \"Artifacts\" from Flux rendering nodes in a card grid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
import { getRenderOutputCache, formatTimeSinceLastUpdate } from '../state/recollectionStore'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function KatalogosPage() {
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const { recollections } = usePlatform()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||||
|
const title = recollection?.name ?? 'Untitled'
|
||||||
|
|
||||||
|
const artifacts = useMemo(
|
||||||
|
() => (recollectionId ? getRenderOutputCache(recollectionId) : []),
|
||||||
|
[recollectionId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleViewInFlux = (nodeId: string) => {
|
||||||
|
if (!recollectionId) return
|
||||||
|
navigate(`/recollections/${recollectionId}/flux?focusNode=${encodeURIComponent(nodeId)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||||
|
<div className="mx-auto w-full max-w-5xl p-4">
|
||||||
|
<h1 className="mb-2 truncate font-serif text-2xl font-semibold tracking-tight text-foreground">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
|
Katalogos · Live artifacts produced by Flux rendering nodes for this recollection.
|
||||||
|
</p>
|
||||||
|
{artifacts.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground">
|
||||||
|
No artifacts yet. In Flux, run a graph with a rendering node; its output will be cached as an artifact and
|
||||||
|
appear here, as well as in Logos blocks that insert artifacts.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{artifacts.map((artifact) => {
|
||||||
|
const label = artifact.label || artifact.nodeId
|
||||||
|
const isImage =
|
||||||
|
artifact.type === 'image' ||
|
||||||
|
Boolean(artifact.content?.trim() && /<svg[\\s>]/i.test(artifact.content.trim()))
|
||||||
|
const imageSrc =
|
||||||
|
isImage && artifact.content
|
||||||
|
? artifact.content.startsWith('data:')
|
||||||
|
? artifact.content
|
||||||
|
: `data:image/svg+xml;utf8,${encodeURIComponent(artifact.content)}`
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={artifact.nodeId}
|
||||||
|
className="flex h-full flex-col rounded-lg border border-border bg-card text-card-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{label}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">Node: {artifact.nodeId}</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-emerald-500">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-2 px-3 py-3">
|
||||||
|
{isImage && imageSrc ? (
|
||||||
|
<div className="flex min-h-[120px] items-center justify-center overflow-hidden rounded-md border bg-muted">
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={label || 'Artifact image'}
|
||||||
|
className="max-h-48 w-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : artifact.content ? (
|
||||||
|
<div className="min-h-[120px] overflow-hidden rounded-md border border-border bg-background">
|
||||||
|
<iframe
|
||||||
|
title={label || 'Artifact HTML output'}
|
||||||
|
srcDoc={artifact.content}
|
||||||
|
className="h-40 w-full"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[80px] items-center justify-center rounded-md border border-dashed border-muted-foreground/40 bg-muted/40 px-3 text-xs text-muted-foreground">
|
||||||
|
No preview available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-card px-3 py-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
Type: <span className="font-medium">{artifact.type}</span>
|
||||||
|
</p>
|
||||||
|
{artifact.updatedAt != null && (
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
· {formatTimeSinceLastUpdate(artifact.updatedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => handleViewInFlux(artifact.nodeId)}
|
||||||
|
>
|
||||||
|
View in Flux
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Consumers use pathname to pick the active slot for title (save status, Save) and Edit/View menus (undo, redo, etc.).
|
* Consumers use pathname to pick the active slot for title (save status, Save) and Edit/View menus (undo, redo, etc.).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useRef, useState } from 'react'
|
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
import { useLocation, useParams } from 'react-router-dom'
|
import { useLocation, useParams } from 'react-router-dom'
|
||||||
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
|
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
type StoredGraphState,
|
type StoredGraphState,
|
||||||
type StoredLogosContent,
|
type StoredLogosContent,
|
||||||
type RenderOutputCacheEntry,
|
type RenderOutputCacheEntry,
|
||||||
} from './recollectionStore'
|
} from '../state/recollectionStore'
|
||||||
|
|
||||||
export type { RenderOutputCacheEntry }
|
export type { RenderOutputCacheEntry }
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
@@ -120,7 +120,7 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
|
|||||||
pathnameRef.current = pathname
|
pathnameRef.current = pathname
|
||||||
|
|
||||||
const isFluxActive = pathname.endsWith('/flux')
|
const isFluxActive = pathname.endsWith('/flux')
|
||||||
const isLogosActive = pathname.endsWith('/logos') || /\/recollections\/[^/]+\/?$/.test(pathname)
|
const isLogosActive = pathname.includes('/logos') || /\/recollections\/[^/]+\/?$/.test(pathname)
|
||||||
const activeSlot = isFluxActive ? flux : isLogosActive ? logos : null
|
const activeSlot = isFluxActive ? flux : isLogosActive ? logos : null
|
||||||
|
|
||||||
const setFluxSlot = useCallback((slot: FluxSlot | null) => {
|
const setFluxSlot = useCallback((slot: FluxSlot | null) => {
|
||||||
@@ -130,6 +130,22 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
|
|||||||
setLogosSlotState(() => slot)
|
setLogosSlotState(() => slot)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
const mod = ev.ctrlKey || ev.metaKey
|
||||||
|
if (mod && ev.key.toLowerCase() === 's') {
|
||||||
|
const slot = activeSlot
|
||||||
|
if (slot?.onSave && slot?.canSave) {
|
||||||
|
ev.preventDefault()
|
||||||
|
ev.stopPropagation()
|
||||||
|
slot.onSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
|
}, [activeSlot])
|
||||||
|
|
||||||
const onExport = useCallback(() => {
|
const onExport = useCallback(() => {
|
||||||
if (!recollectionId) return
|
if (!recollectionId) return
|
||||||
const graph = getGraph(recollectionId)
|
const graph = getGraph(recollectionId)
|
||||||
@@ -179,7 +195,7 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
|
|||||||
)
|
)
|
||||||
const currentPath = pathnameRef.current
|
const currentPath = pathnameRef.current
|
||||||
const fluxActive = currentPath.endsWith('/flux')
|
const fluxActive = currentPath.endsWith('/flux')
|
||||||
const logosActive = currentPath.endsWith('/logos') || /\/recollections\/[^/]+\/?$/.test(currentPath)
|
const logosActive = currentPath.includes('/logos') || /\/recollections\/[^/]+\/?$/.test(currentPath)
|
||||||
if (written.graph && fluxActive && fluxRef.current?.onRefreshFromStore) {
|
if (written.graph && fluxActive && fluxRef.current?.onRefreshFromStore) {
|
||||||
fluxRef.current.onRefreshFromStore(written.graph)
|
fluxRef.current.onRefreshFromStore(written.graph)
|
||||||
}
|
}
|
||||||
@@ -46,17 +46,21 @@ export function RecollectionEditViewMenus() {
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
}, [activeSlot])
|
}, [activeSlot])
|
||||||
|
|
||||||
const menus = useMemo(() => {
|
const hasFluxOnly =
|
||||||
if (!activeSlot) return null
|
fluxSlot &&
|
||||||
const hasFluxOnly =
|
(fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null)
|
||||||
fluxSlot &&
|
|
||||||
(fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null)
|
const menus = useMemo(
|
||||||
return (
|
() => (
|
||||||
<Menubar className="shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
|
<Menubar className="h-9 shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
|
<MenubarTrigger className="h-9 font-normal text-muted-foreground">Edit</MenubarTrigger>
|
||||||
<MenubarContent>
|
<MenubarContent>
|
||||||
<MenubarItem onClick={activeSlot.undo} disabled={!activeSlot.canUndo} className="gap-2">
|
<MenubarItem
|
||||||
|
onClick={() => activeSlot?.undo()}
|
||||||
|
disabled={!activeSlot?.canUndo}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
<Undo2 className="h-4 w-4" />
|
<Undo2 className="h-4 w-4" />
|
||||||
Undo
|
Undo
|
||||||
<span className="ml-auto pl-4">
|
<span className="ml-auto pl-4">
|
||||||
@@ -65,7 +69,11 @@ export function RecollectionEditViewMenus() {
|
|||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
</span>
|
</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
<MenubarItem onClick={activeSlot.redo} disabled={!activeSlot.canRedo} className="gap-2">
|
<MenubarItem
|
||||||
|
onClick={() => activeSlot?.redo()}
|
||||||
|
disabled={!activeSlot?.canRedo}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
<Redo2 className="h-4 w-4" />
|
<Redo2 className="h-4 w-4" />
|
||||||
Redo
|
Redo
|
||||||
<span className="ml-auto pl-4">
|
<span className="ml-auto pl-4">
|
||||||
@@ -74,13 +82,13 @@ export function RecollectionEditViewMenus() {
|
|||||||
</KbdGroup>
|
</KbdGroup>
|
||||||
</span>
|
</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
{hasFluxOnly && (
|
{hasFluxOnly && fluxSlot && (
|
||||||
<>
|
<>
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
{fluxSlot!.onDuplicate != null && (
|
{fluxSlot.onDuplicate != null && (
|
||||||
<MenubarItem
|
<MenubarItem
|
||||||
onClick={fluxSlot!.onDuplicate}
|
onClick={fluxSlot.onDuplicate}
|
||||||
disabled={!fluxSlot!.canDuplicate}
|
disabled={!fluxSlot.canDuplicate}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<CopyPlus className="h-4 w-4" />
|
<CopyPlus className="h-4 w-4" />
|
||||||
@@ -92,10 +100,10 @@ export function RecollectionEditViewMenus() {
|
|||||||
</span>
|
</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
)}
|
)}
|
||||||
{fluxSlot!.onCopy != null && (
|
{fluxSlot.onCopy != null && (
|
||||||
<MenubarItem
|
<MenubarItem
|
||||||
onClick={fluxSlot!.onCopy}
|
onClick={fluxSlot.onCopy}
|
||||||
disabled={!fluxSlot!.canCopy}
|
disabled={!fluxSlot.canCopy}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
@@ -107,8 +115,8 @@ export function RecollectionEditViewMenus() {
|
|||||||
</span>
|
</span>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
)}
|
)}
|
||||||
{fluxSlot!.onPaste != null && (
|
{fluxSlot.onPaste != null && (
|
||||||
<MenubarItem onClick={fluxSlot!.onPaste} className="gap-2">
|
<MenubarItem onClick={fluxSlot.onPaste} className="gap-2">
|
||||||
<ClipboardPaste className="h-4 w-4" />
|
<ClipboardPaste className="h-4 w-4" />
|
||||||
Paste
|
Paste
|
||||||
<span className="ml-auto pl-4">
|
<span className="ml-auto pl-4">
|
||||||
@@ -123,7 +131,7 @@ export function RecollectionEditViewMenus() {
|
|||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
|
<MenubarTrigger className="h-9 font-normal text-muted-foreground">View</MenubarTrigger>
|
||||||
<MenubarContent>
|
<MenubarContent>
|
||||||
{fluxSlot?.onFitView && (
|
{fluxSlot?.onFitView && (
|
||||||
<MenubarItem onClick={fluxSlot.onFitView} className="gap-2">
|
<MenubarItem onClick={fluxSlot.onFitView} className="gap-2">
|
||||||
@@ -138,8 +146,9 @@ export function RecollectionEditViewMenus() {
|
|||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
</Menubar>
|
</Menubar>
|
||||||
)
|
),
|
||||||
}, [activeSlot, fluxSlot])
|
[activeSlot, fluxSlot, hasFluxOnly]
|
||||||
|
)
|
||||||
|
|
||||||
return menus
|
return menus
|
||||||
}
|
}
|
||||||
102
frontend/src/app/recollections/layout/RecollectionFileMenu.tsx
Normal file
102
frontend/src/app/recollections/layout/RecollectionFileMenu.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* File menu for a recollection (emanation).
|
||||||
|
* Hosts Save, Rename, Import, Export that used to live under the title dropdown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||||
|
import { useRecollectionActions } from './RecollectionActionsContext'
|
||||||
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
|
||||||
|
import { Download, FolderOpen, Pencil, Save } from 'lucide-react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
export function RecollectionFileMenu() {
|
||||||
|
const { activeSlot, onImport, onExport } = useRecollectionActions()
|
||||||
|
const { recollections, renameRecollection } = usePlatform()
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
|
||||||
|
const [renameModalOpen, setRenameModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const onSave = activeSlot?.onSave
|
||||||
|
const canSave = activeSlot?.canSave ?? false
|
||||||
|
|
||||||
|
const recollectionName = useMemo(
|
||||||
|
() => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? '' : ''),
|
||||||
|
[recollectionId, recollections]
|
||||||
|
)
|
||||||
|
|
||||||
|
const menuItems = useMemo(
|
||||||
|
() => (
|
||||||
|
<>
|
||||||
|
{recollectionId && onSave != null && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={onSave} disabled={!canSave} className="gap-2">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Save
|
||||||
|
<span className="ml-auto pl-4">
|
||||||
|
<KbdGroup>
|
||||||
|
<Kbd>⌘S</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{recollectionId && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={() => setRenameModalOpen(true)} className="gap-2">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={onImport} className="gap-2">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
Import…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onExport} className="gap-2">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Export…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[recollectionId, onSave, canSave, onImport, onExport]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="flex h-9 shrink-0 items-center rounded-sm px-2 py-0 text-sm font-normal text-muted-foreground outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent">
|
||||||
|
File
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuContent align="start" className="z-[200] min-w-[12rem]">
|
||||||
|
{menuItems}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenu>
|
||||||
|
<RenameRecollectionDialog
|
||||||
|
open={renameModalOpen}
|
||||||
|
onOpenChange={setRenameModalOpen}
|
||||||
|
recollectionId={recollectionId ?? ''}
|
||||||
|
initialName={recollectionName}
|
||||||
|
recollections={recollections}
|
||||||
|
onRename={(id, newName) => {
|
||||||
|
renameRecollection(id, newName)
|
||||||
|
setRenameModalOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
114
frontend/src/app/recollections/layout/RecollectionMenubar.tsx
Normal file
114
frontend/src/app/recollections/layout/RecollectionMenubar.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Recollection menubar: Back | breadcrumbs (left) | view switcher + File + Edit + save status (center).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { Link, useLocation, useParams } from 'react-router-dom'
|
||||||
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from '@/components/ui/breadcrumb'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { CheckCircle2, CircleDot, Loader2 } from 'lucide-react'
|
||||||
|
import { RecollectionEditViewMenus } from './RecollectionEditViewMenus'
|
||||||
|
import { RecollectionViewSwitcher } from './RecollectionViewSwitcher'
|
||||||
|
import { RecollectionFileMenu } from './RecollectionFileMenu'
|
||||||
|
import { useRecollectionActions } from './RecollectionActionsContext'
|
||||||
|
import { getMode, viewLabel } from './recollectionNav'
|
||||||
|
|
||||||
|
export function RecollectionMenubar() {
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const { recollections } = usePlatform()
|
||||||
|
const { activeSlot } = useRecollectionActions()
|
||||||
|
const saveStatus = activeSlot?.saveStatus ?? null
|
||||||
|
|
||||||
|
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||||
|
const mode = getMode(pathname)
|
||||||
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-9 w-full shrink-0 items-center gap-2 overflow-visible border-b border-border/40 bg-background px-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{base && (
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList className="text-xs">
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link
|
||||||
|
to="/recollections"
|
||||||
|
aria-label="Back to recollections"
|
||||||
|
className="flex shrink-0 items-center rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="size-4 shrink-0 rounded-[2px] opacity-90"
|
||||||
|
style={{
|
||||||
|
maskImage: 'url(/app-icon.svg)',
|
||||||
|
maskRepeat: 'no-repeat',
|
||||||
|
maskPosition: 'center',
|
||||||
|
maskSize: 'contain',
|
||||||
|
WebkitMaskImage: 'url(/app-icon.svg)',
|
||||||
|
WebkitMaskRepeat: 'no-repeat',
|
||||||
|
WebkitMaskPosition: 'center',
|
||||||
|
WebkitMaskSize: 'contain',
|
||||||
|
backgroundColor: 'currentColor',
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link to={base}>{recollection?.name ?? 'Recollection'}</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbPage>{viewLabel(mode)}</BreadcrumbPage>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute left-1/2 top-1/2 flex min-w-0 -translate-x-1/2 -translate-y-1/2 items-center justify-center">
|
||||||
|
<div className="pointer-events-auto flex min-w-0 items-center gap-2">
|
||||||
|
<RecollectionViewSwitcher />
|
||||||
|
<RecollectionFileMenu />
|
||||||
|
<RecollectionEditViewMenus />
|
||||||
|
{/* Fixed-width slot so layout does not jump when saveStatus is null (e.g. on Katalogos) */}
|
||||||
|
<span
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center text-muted-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={
|
||||||
|
saveStatus === 'saving' ? 'Saving' : saveStatus === 'unsaved' ? 'Unsaved changes' : saveStatus === 'saved' ? 'All changes saved' : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{saveStatus != null && (
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="flex size-5 items-center justify-center">
|
||||||
|
{saveStatus === 'saving' && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
||||||
|
{saveStatus === 'unsaved' && <CircleDot className="size-3.5" aria-hidden />}
|
||||||
|
{saveStatus === 'saved' && <CheckCircle2 className="size-3.5 text-muted-foreground/70" aria-hidden />}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
frontend/src/app/recollections/layout/RecollectionSidebar.tsx
Normal file
111
frontend/src/app/recollections/layout/RecollectionSidebar.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Plus, FileText } from 'lucide-react'
|
||||||
|
import { FluxIcon } from '@/lib/icons'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||||
|
import { TreeBrowser } from './TreeBrowser'
|
||||||
|
import {
|
||||||
|
SidebarContent,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuItem,
|
||||||
|
sidebarMenuButtonVariants,
|
||||||
|
} from '@/components/ui/sidebar'
|
||||||
|
|
||||||
|
export function RecollectionSidebar() {
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||||
|
|
||||||
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
|
const baseLogos = `${base}/logos`
|
||||||
|
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
||||||
|
const isFluxView = pathname.endsWith('/flux')
|
||||||
|
|
||||||
|
const handleAddPage = useCallback(() => {
|
||||||
|
// Add a new top-level page
|
||||||
|
const maxPos = Math.max(0, ...tree.filter((p) => p.parentId === null).map((p) => p.position), -1)
|
||||||
|
const newPage = {
|
||||||
|
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
title: 'Untitled',
|
||||||
|
parentId: null,
|
||||||
|
position: maxPos + 1,
|
||||||
|
}
|
||||||
|
handleTreeChange([...tree, newPage])
|
||||||
|
handleSelectPage(newPage.id)
|
||||||
|
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
|
||||||
|
}, [tree, handleTreeChange, handleSelectPage, baseLogos, navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
|
||||||
|
style={{ '--sidebar-width': '16rem' } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
|
||||||
|
<SidebarGroup>
|
||||||
|
<div className="flex items-center justify-between gap-2 py-1.5">
|
||||||
|
<SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
onClick={handleAddPage}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
New page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<TreeBrowser />
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Katalogos</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-active={isKatalogosView}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
||||||
|
onClick={() => navigate(`${base}/logos/katalogos`)}
|
||||||
|
>
|
||||||
|
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
|
<span className="truncate">Artifacts</span>
|
||||||
|
</button>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Flux</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-active={isFluxView}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
||||||
|
onClick={() => navigate(`${base}/flux`)}
|
||||||
|
>
|
||||||
|
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
|
<span className="truncate">Canvas</span>
|
||||||
|
</button>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Context for the recollection sidebar: Logos page tree and active page, shared by
|
||||||
|
* RecollectionSidebar and LogosPage. Load/persist and URL sync live here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||||
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
getLogosContent,
|
||||||
|
getLogosPageTree,
|
||||||
|
setLogosPageTree,
|
||||||
|
setLogosContentForPage,
|
||||||
|
removeLogosPageContent,
|
||||||
|
type LogosPageMeta,
|
||||||
|
type LogosPageId,
|
||||||
|
} from '../state/recollectionStore'
|
||||||
|
|
||||||
|
const MAIN_PAGE_ID: LogosPageId = 'main'
|
||||||
|
|
||||||
|
export type RecollectionSidebarContextValue = {
|
||||||
|
tree: LogosPageMeta[]
|
||||||
|
activePageId: LogosPageId | null
|
||||||
|
setTree: React.Dispatch<React.SetStateAction<LogosPageMeta[]>>
|
||||||
|
handleSelectPage: (id: LogosPageId) => void
|
||||||
|
handleTreeChange: (tree: LogosPageMeta[]) => void
|
||||||
|
handleDeletePage: (pageId: LogosPageId) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecollectionSidebarContext = createContext<RecollectionSidebarContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useRecollectionSidebar(): RecollectionSidebarContextValue {
|
||||||
|
const ctx = useContext(RecollectionSidebarContext)
|
||||||
|
if (!ctx) throw new Error('useRecollectionSidebar must be used within RecollectionSidebarProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecollectionSidebarProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const [tree, setTree] = useState<LogosPageMeta[]>([])
|
||||||
|
const [activePageId, setActivePageId] = useState<LogosPageId | null>(null)
|
||||||
|
|
||||||
|
const baseLogosPath = recollectionId ? `/recollections/${recollectionId}/logos` : ''
|
||||||
|
|
||||||
|
const handleSelectPage = useCallback(
|
||||||
|
(id: LogosPageId) => {
|
||||||
|
setActivePageId(id)
|
||||||
|
if (recollectionId) {
|
||||||
|
setSearchParams({ page: id }, { replace: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[recollectionId, setSearchParams]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTreeChange = useCallback((newTree: LogosPageMeta[]) => {
|
||||||
|
setTree(newTree)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeletePage = useCallback(
|
||||||
|
(pageId: LogosPageId) => {
|
||||||
|
if (recollectionId) removeLogosPageContent(recollectionId, pageId)
|
||||||
|
},
|
||||||
|
[recollectionId]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load page tree from storage only when recollection changes (not on every searchParams change).
|
||||||
|
// Otherwise adding a new page would trigger searchParams change and this would overwrite tree before persist.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollectionId) return
|
||||||
|
let t = getLogosPageTree(recollectionId)
|
||||||
|
if (t.length === 0) {
|
||||||
|
const legacy = getLogosContent(recollectionId)
|
||||||
|
const main: LogosPageMeta = { id: MAIN_PAGE_ID, title: 'Main', parentId: null, position: 0 }
|
||||||
|
setLogosContentForPage(recollectionId, MAIN_PAGE_ID, legacy ?? [])
|
||||||
|
setLogosPageTree(recollectionId, [main])
|
||||||
|
t = [main]
|
||||||
|
}
|
||||||
|
setTree(t)
|
||||||
|
}, [recollectionId])
|
||||||
|
|
||||||
|
// Sync activePageId from URL when searchParams or tree changes (e.g. after adding a page).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollectionId) return
|
||||||
|
const pageFromUrl = searchParams.get('page')
|
||||||
|
setActivePageId((prev) => {
|
||||||
|
const validIdFromUrl = pageFromUrl && tree.some((p) => p.id === pageFromUrl) ? pageFromUrl : null
|
||||||
|
if (validIdFromUrl) return validIdFromUrl
|
||||||
|
const firstId = tree[0]?.id ?? null
|
||||||
|
if (prev != null && tree.some((p) => p.id === prev)) return prev
|
||||||
|
return firstId
|
||||||
|
})
|
||||||
|
}, [recollectionId, searchParams, tree])
|
||||||
|
|
||||||
|
// Persist tree when sidebar changes it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recollectionId || tree.length === 0) return
|
||||||
|
setLogosPageTree(recollectionId, tree)
|
||||||
|
}, [recollectionId, tree])
|
||||||
|
|
||||||
|
const value: RecollectionSidebarContextValue = {
|
||||||
|
tree,
|
||||||
|
activePageId,
|
||||||
|
setTree,
|
||||||
|
handleSelectPage,
|
||||||
|
handleTreeChange,
|
||||||
|
handleDeletePage,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RecollectionSidebarContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</RecollectionSidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* View switcher: circle with current view icon; click cycles Logos → Katalogos → Flux.
|
||||||
|
* Keyboard shortcut: ⌘⇧↑ / ⌘⇧↓ to cycle view.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo } from 'react'
|
||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { FluxIcon } from '@/lib/icons'
|
||||||
|
import { FileText, LayoutGrid } from 'lucide-react'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { getMode, pathForMode, RECOLLECTION_VIEW_MODES, viewLabel } from './recollectionNav'
|
||||||
|
|
||||||
|
const ICON_SLOT_SIZE_REM = 2 // size-8 = 2rem
|
||||||
|
|
||||||
|
function shortcutLabel() {
|
||||||
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
|
||||||
|
return isMac ? '⌘⇧↑ / ⌘⇧↓' : 'Ctrl+Shift+↑ / Ctrl+Shift+↓'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecollectionViewSwitcher() {
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
|
|
||||||
|
const mode = useMemo(() => getMode(pathname), [pathname])
|
||||||
|
const modeIndex = RECOLLECTION_VIEW_MODES.indexOf(mode)
|
||||||
|
|
||||||
|
const goRelative = useCallback(
|
||||||
|
(delta: 1 | -1) => {
|
||||||
|
if (!base) return
|
||||||
|
const nextIndex = (modeIndex + delta + RECOLLECTION_VIEW_MODES.length) % RECOLLECTION_VIEW_MODES.length
|
||||||
|
navigate(pathForMode(base, RECOLLECTION_VIEW_MODES[nextIndex]))
|
||||||
|
},
|
||||||
|
[base, modeIndex, navigate]
|
||||||
|
)
|
||||||
|
|
||||||
|
const cycle = useCallback(() => {
|
||||||
|
goRelative(1)
|
||||||
|
}, [goRelative])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
const isMod = ev.ctrlKey || ev.metaKey
|
||||||
|
const isShortcut = isMod && ev.shiftKey && (ev.key === 'ArrowDown' || ev.key === 'ArrowUp')
|
||||||
|
if (!isShortcut) return
|
||||||
|
ev.preventDefault()
|
||||||
|
if (ev.key === 'ArrowDown') goRelative(1)
|
||||||
|
else if (ev.key === 'ArrowUp') goRelative(-1)
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
|
}, [goRelative])
|
||||||
|
|
||||||
|
if (!base) return null
|
||||||
|
|
||||||
|
const tooltipText = `${viewLabel(mode)} (${shortcutLabel()})`
|
||||||
|
const translateY = -modeIndex * ICON_SLOT_SIZE_REM
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 shrink-0 items-center">
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cycle}
|
||||||
|
aria-label={tooltipText}
|
||||||
|
className="flex size-8 shrink-0 items-start justify-center overflow-hidden rounded-full transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-col transition-transform duration-200 ease-out"
|
||||||
|
style={{ transform: `translateY(${translateY}rem)` }}
|
||||||
|
>
|
||||||
|
<div className="flex size-8 flex-shrink-0 items-center justify-center [&_svg]:shrink-0">
|
||||||
|
<FileText className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex size-8 flex-shrink-0 items-center justify-center [&_svg]:shrink-0">
|
||||||
|
<LayoutGrid className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex size-8 flex-shrink-0 items-center justify-center [&_svg]:shrink-0">
|
||||||
|
<span className="inline-flex size-3.5 shrink-0 items-center justify-center">
|
||||||
|
<FluxIcon className="size-full text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{tooltipText}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Shared modal for renaming a recollection. Used by RecollectionsPage and RecollectionTitleContent.
|
* Shared modal for renaming a recollection. Used by RecollectionsPage and RecollectionFileMenu.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
609
frontend/src/app/recollections/layout/TreeBrowser.tsx
Normal file
609
frontend/src/app/recollections/layout/TreeBrowser.tsx
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
/**
|
||||||
|
* TreeBrowser: A tree browser component using react-arborist for managing
|
||||||
|
* Logos pages in a hierarchical folder structure.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Drag and drop reordering
|
||||||
|
* - Folder expansion/collapse
|
||||||
|
* - Context menu for actions
|
||||||
|
* - Shadcn theme integration
|
||||||
|
* - Inline editing for page titles
|
||||||
|
* - Smooth animations
|
||||||
|
* - Search functionality
|
||||||
|
* - Expand/collapse all
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
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,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||||
|
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import type { LogosPageMeta } from '../state/recollectionStore'
|
||||||
|
import { removeLogosPageContent } from '../state/recollectionStore'
|
||||||
|
|
||||||
|
// Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object).
|
||||||
|
type TreeNode = LogosPageMeta & {
|
||||||
|
children?: TreeNode[]
|
||||||
|
isFolder: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert flat tree to hierarchical structure
|
||||||
|
function buildTree(pages: LogosPageMeta[]): TreeNode[] {
|
||||||
|
const pageMap = new Map<string, TreeNode>()
|
||||||
|
const rootNodes: TreeNode[] = []
|
||||||
|
|
||||||
|
// First pass: create all nodes
|
||||||
|
pages.forEach((page) => {
|
||||||
|
pageMap.set(page.id, {
|
||||||
|
...page,
|
||||||
|
children: [],
|
||||||
|
isFolder: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Second pass: build parent-child relationships
|
||||||
|
pages.forEach((page) => {
|
||||||
|
const node = pageMap.get(page.id)!
|
||||||
|
if (page.parentId === null) {
|
||||||
|
rootNodes.push(node)
|
||||||
|
} else {
|
||||||
|
const parent = pageMap.get(page.parentId)
|
||||||
|
if (parent) {
|
||||||
|
parent.children?.push(node)
|
||||||
|
parent.isFolder = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort children by position
|
||||||
|
function sortNodes(nodes: TreeNode[]) {
|
||||||
|
nodes.sort((a, b) => a.position - b.position)
|
||||||
|
nodes.forEach((node) => sortNodes(node.children || []))
|
||||||
|
}
|
||||||
|
sortNodes(rootNodes)
|
||||||
|
|
||||||
|
return rootNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoize the buildTree result to avoid unnecessary re-creation
|
||||||
|
function useTreeNodes(tree: LogosPageMeta[]) {
|
||||||
|
return useMemo(() => buildTree(tree), [tree])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline editable title component
|
||||||
|
function EditableTitle({
|
||||||
|
title,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
onSave: (newTitle: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
inputRef.current?.select()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onSave(inputRef.current?.value || '')
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
defaultValue={title}
|
||||||
|
onBlur={(e) => onSave(e.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full rounded px-1 py-0.5 text-sm outline-none ring-2 ring-ring focus:ring-2 transition-all duration-200 bg-background"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||||
|
|
||||||
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
|
const baseLogos = `${base}/logos`
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
node.toggle()
|
||||||
|
},
|
||||||
|
[node]
|
||||||
|
)
|
||||||
|
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRename = useCallback(() => {
|
||||||
|
node.edit()
|
||||||
|
}, [node])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
const toDelete = new Set<string>()
|
||||||
|
function collectDescendants(id: string) {
|
||||||
|
toDelete.add(id)
|
||||||
|
tree.forEach((p) => {
|
||||||
|
if (p.parentId === id) collectDescendants(p.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
collectDescendants(node.data.id)
|
||||||
|
if (recollectionId) {
|
||||||
|
toDelete.forEach((pageId) => {
|
||||||
|
removeLogosPageContent(recollectionId, pageId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
handleTreeChange(tree.filter((p) => !toDelete.has(p.id)))
|
||||||
|
}, [tree, handleTreeChange, node.data.id, recollectionId])
|
||||||
|
|
||||||
|
const handleAddChild = useCallback(() => {
|
||||||
|
const maxPos = Math.max(
|
||||||
|
0,
|
||||||
|
...tree.filter((p) => p.parentId === node.data.id).map((p) => p.position),
|
||||||
|
-1
|
||||||
|
)
|
||||||
|
const newPage: LogosPageMeta = {
|
||||||
|
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
title: 'Untitled',
|
||||||
|
parentId: node.data.id,
|
||||||
|
position: maxPos + 1,
|
||||||
|
}
|
||||||
|
handleTreeChange([...tree, newPage])
|
||||||
|
if (!node.isOpen) node.open()
|
||||||
|
}, [tree, handleTreeChange, node])
|
||||||
|
|
||||||
|
const hasChildren = (node.children?.length || 0) > 0
|
||||||
|
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
|
||||||
|
className={cn(
|
||||||
|
'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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
{node.isOpen ? (
|
||||||
|
<ChevronDown className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{node.state.isEditing ? (
|
||||||
|
<EditableTitle
|
||||||
|
title={node.data.title}
|
||||||
|
onSave={(newTitle) => {
|
||||||
|
if (newTitle.trim()) {
|
||||||
|
handleTreeChange(
|
||||||
|
tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
node.submit(newTitle)
|
||||||
|
}}
|
||||||
|
onCancel={() => node.reset()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="block truncate select-none">{pageTitle}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleAddChild()
|
||||||
|
}}
|
||||||
|
title="Add child page"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
</button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenuItem onClick={handleRename}>
|
||||||
|
<Pencil className="mr-2 size-3.5" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 size-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
|
||||||
|
// Filter tree based on search query
|
||||||
|
const filteredTree = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return treeNodes
|
||||||
|
|
||||||
|
const searchTerm = searchQuery.toLowerCase()
|
||||||
|
|
||||||
|
function filterNodes(nodes: TreeNode[]): TreeNode[] {
|
||||||
|
const result: TreeNode[] = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
const matches = node.title.toLowerCase().includes(searchTerm)
|
||||||
|
const children = filterNodes(node.children || [])
|
||||||
|
|
||||||
|
if (matches || children.length > 0) {
|
||||||
|
result.push({
|
||||||
|
...node,
|
||||||
|
children: children.length > 0 ? children : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return filterNodes(treeNodes)
|
||||||
|
}, [treeNodes, searchQuery])
|
||||||
|
|
||||||
|
// Get all folder IDs for initial open state
|
||||||
|
const initialOpenState = useMemo(() => {
|
||||||
|
const folderIds: Record<string, boolean> = {}
|
||||||
|
function collectIds(nodes: TreeNode[]) {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.isFolder) {
|
||||||
|
folderIds[node.id] = true
|
||||||
|
collectIds(node.children || [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
collectIds(treeNodes)
|
||||||
|
return folderIds
|
||||||
|
}, [treeNodes])
|
||||||
|
|
||||||
|
// Handle drag and drop reordering (react-arborist onMove)
|
||||||
|
const handleMove = useCallback(
|
||||||
|
({
|
||||||
|
dragIds,
|
||||||
|
parentId: newParentId,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
dragIds: string[]
|
||||||
|
parentId: string | null
|
||||||
|
index: number
|
||||||
|
}) => {
|
||||||
|
const dragId = dragIds[0]
|
||||||
|
if (!dragId) return
|
||||||
|
|
||||||
|
const dragged = tree.find((p) => p.id === dragId)
|
||||||
|
if (!dragged) return
|
||||||
|
|
||||||
|
const oldParentId = dragged.parentId
|
||||||
|
const moved: LogosPageMeta = { ...dragged, parentId: newParentId }
|
||||||
|
|
||||||
|
const newSiblings = tree
|
||||||
|
.filter((p) => p.parentId === newParentId && p.id !== dragId)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
newSiblings.splice(index, 0, moved)
|
||||||
|
|
||||||
|
const updates = new Map<string, LogosPageMeta>()
|
||||||
|
newSiblings.forEach((p, i) => {
|
||||||
|
updates.set(p.id, { ...p, parentId: newParentId, position: i })
|
||||||
|
})
|
||||||
|
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expand all folders
|
||||||
|
const handleExpandAll = useCallback(() => {
|
||||||
|
treeRef.current?.openAll()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Collapse all folders
|
||||||
|
const handleCollapseAll = useCallback(() => {
|
||||||
|
treeRef.current?.closeAll()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
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">
|
||||||
|
<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
|
||||||
|
type="text"
|
||||||
|
placeholder="Search pages..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
title="Expand/Collapse options"
|
||||||
|
>
|
||||||
|
<ChevronsUpDown className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handleExpandAll}>
|
||||||
|
<ChevronsDownUp className="mr-2 size-3.5" />
|
||||||
|
Expand all
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleCollapseAll}>
|
||||||
|
<ChevronsUpDown className="mr-2 size-3.5" />
|
||||||
|
Collapse all
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tree */}
|
||||||
|
<div ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<Tree ref={treeRef} {...arboristTreeProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
frontend/src/app/recollections/layout/recollectionNav.ts
Normal file
25
frontend/src/app/recollections/layout/recollectionNav.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Recollection navigation helpers: current view mode from pathname, labels for breadcrumbs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RecollectionViewMode = 'logos' | 'katalogos' | 'flux'
|
||||||
|
|
||||||
|
export function getMode(pathname: string): RecollectionViewMode {
|
||||||
|
if (pathname.endsWith('/flux')) return 'flux'
|
||||||
|
if (pathname.endsWith('/logos/katalogos')) return 'katalogos'
|
||||||
|
return 'logos'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewLabel(mode: RecollectionViewMode): string {
|
||||||
|
if (mode === 'katalogos') return 'Katalogos'
|
||||||
|
if (mode === 'flux') return 'Flux'
|
||||||
|
return 'Logos'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECOLLECTION_VIEW_MODES: RecollectionViewMode[] = ['logos', 'katalogos', 'flux']
|
||||||
|
|
||||||
|
export function pathForMode(base: string, mode: RecollectionViewMode): string {
|
||||||
|
if (mode === 'flux') return `${base}/flux`
|
||||||
|
if (mode === 'katalogos') return `${base}/logos/katalogos`
|
||||||
|
return `${base}/logos`
|
||||||
|
}
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Logos page: BlockNote editor for the recollection. Content persisted in recollection store.
|
* Logos page: BlockNote editor for the recollection. Content only; sidebar is at layout level.
|
||||||
* Layout and styling aligned with Flux (same flex/overflow, bg-background, theme).
|
* Content persisted in recollection store.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, useLocation, useSearchParams } from 'react-router-dom'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { FluxIcon } from '@/lib/icons'
|
import { FluxIcon } from '@/lib/icons'
|
||||||
import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuController } from '@blocknote/react'
|
import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuController } from '@blocknote/react'
|
||||||
import { BlockNoteView } from '@blocknote/shadcn'
|
import { BlockNoteView } from '@blocknote/shadcn'
|
||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import { useRecollectionActions } from '../RecollectionActionsContext'
|
import { useRecollectionActions } from '../layout/RecollectionActionsContext'
|
||||||
import { getLogosContent, setLogosContent, type StoredLogosContent } from '../recollectionStore'
|
import { useRecollectionSidebar } from '../layout/RecollectionSidebarContext'
|
||||||
|
import {
|
||||||
|
getLogosContentForPage,
|
||||||
|
setLogosContentForPage,
|
||||||
|
type StoredLogosContent,
|
||||||
|
} from '../state/recollectionStore'
|
||||||
import { logosSchema } from './logosSchema'
|
import { logosSchema } from './logosSchema'
|
||||||
|
import { KatalogosPage } from '../katalogos/KatalogosPage'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
|
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
|
||||||
@@ -50,37 +56,49 @@ function patchBlockNoteRefWarning() {
|
|||||||
|
|
||||||
export function LogosPage() {
|
export function LogosPage() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const { recollections } = usePlatform()
|
const { recollections } = usePlatform()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { setLogosSlot } = useRecollectionActions()
|
const { setLogosSlot } = useRecollectionActions()
|
||||||
|
const { tree, activePageId } = useRecollectionSidebar()
|
||||||
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||||
const title = recollection?.name ?? 'Untitled'
|
|
||||||
const [reloadKey, setReloadKey] = useState(0)
|
const [reloadKey, setReloadKey] = useState(0)
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved')
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved')
|
||||||
|
|
||||||
const initialContent = useMemo(
|
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
||||||
() => (recollectionId ? getLogosContent(recollectionId) ?? undefined : undefined),
|
|
||||||
[recollectionId, reloadKey]
|
// When on Logos (not Katalogos) with a selected page but no page param, sync URL so refresh keeps the page.
|
||||||
)
|
useEffect(() => {
|
||||||
|
if (isKatalogosView || !activePageId || searchParams.get('page') === activePageId) return
|
||||||
|
setSearchParams({ page: activePageId }, { replace: true })
|
||||||
|
}, [isKatalogosView, activePageId, searchParams, setSearchParams])
|
||||||
|
|
||||||
|
const initialContent = useMemo(() => {
|
||||||
|
if (!recollectionId || !activePageId) return undefined
|
||||||
|
const content = getLogosContentForPage(recollectionId, activePageId)
|
||||||
|
if (!content || !Array.isArray(content) || content.length === 0) return undefined
|
||||||
|
return content
|
||||||
|
}, [recollectionId, activePageId, reloadKey])
|
||||||
|
|
||||||
const editor = useCreateBlockNote(
|
const editor = useCreateBlockNote(
|
||||||
{ schema: logosSchema, initialContent },
|
{ schema: logosSchema, initialContent },
|
||||||
[recollectionId, reloadKey]
|
[recollectionId, activePageId, reloadKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
const persistContent = useCallback(() => {
|
const persistContent = useCallback(() => {
|
||||||
if (!recollectionId || !editor) return
|
if (!recollectionId || !activePageId || !editor) return
|
||||||
setSaveStatus('saving')
|
setSaveStatus('saving')
|
||||||
try {
|
try {
|
||||||
const doc = editor.document
|
const doc = editor.document
|
||||||
const serialized = JSON.parse(JSON.stringify(doc)) as StoredLogosContent
|
const serialized = JSON.parse(JSON.stringify(doc)) as StoredLogosContent
|
||||||
setLogosContent(recollectionId, serialized)
|
setLogosContentForPage(recollectionId, activePageId, serialized)
|
||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
} catch {
|
} catch {
|
||||||
setSaveStatus('unsaved')
|
setSaveStatus('unsaved')
|
||||||
}
|
}
|
||||||
}, [recollectionId, editor])
|
}, [recollectionId, activePageId, editor])
|
||||||
|
|
||||||
const onSave = useCallback(() => {
|
const onSave = useCallback(() => {
|
||||||
persistContent()
|
persistContent()
|
||||||
@@ -88,7 +106,7 @@ export function LogosPage() {
|
|||||||
}, [persistContent])
|
}, [persistContent])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !recollectionId) return
|
if (!editor || !recollectionId || !activePageId) return
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
setSaveStatus('unsaved')
|
setSaveStatus('unsaved')
|
||||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||||
@@ -104,10 +122,13 @@ export function LogosPage() {
|
|||||||
saveTimeoutRef.current = null
|
saveTimeoutRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, recollectionId, persistContent])
|
}, [editor, recollectionId, activePageId, persistContent])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || !recollectionId) return
|
if (isKatalogosView || !editor || !recollectionId || !activePageId) {
|
||||||
|
if (isKatalogosView) setLogosSlot(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
const slot = {
|
const slot = {
|
||||||
saveStatus,
|
saveStatus,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -120,29 +141,9 @@ export function LogosPage() {
|
|||||||
}
|
}
|
||||||
setLogosSlot(slot)
|
setLogosSlot(slot)
|
||||||
return () => setLogosSlot(null)
|
return () => setLogosSlot(null)
|
||||||
}, [
|
}, [isKatalogosView, setLogosSlot, saveStatus, onSave, editor, recollectionId, activePageId])
|
||||||
setLogosSlot,
|
|
||||||
saveStatus,
|
|
||||||
onSave,
|
|
||||||
editor,
|
|
||||||
recollectionId,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!recollectionId) {
|
const activePage = tree.find((p) => p.id === activePageId)
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
|
|
||||||
<p className="text-sm">No recollection selected.</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editor) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
|
|
||||||
<p className="text-sm">Loading…</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
patchBlockNoteRefWarning()
|
patchBlockNoteRefWarning()
|
||||||
|
|
||||||
@@ -150,15 +151,15 @@ export function LogosPage() {
|
|||||||
async (query: string) => {
|
async (query: string) => {
|
||||||
const defaultItems = getDefaultReactSlashMenuItems(editor)
|
const defaultItems = getDefaultReactSlashMenuItems(editor)
|
||||||
const fluxItem = {
|
const fluxItem = {
|
||||||
title: 'Insert from Flux',
|
title: 'Insert Artifact',
|
||||||
subtext: 'Insert output from a Flux rendering node',
|
subtext: 'Insert an Artifact produced by a Flux rendering node',
|
||||||
icon: <FluxIcon className="size-4" />,
|
icon: <FluxIcon className="size-4" />,
|
||||||
onItemClick: () => {
|
onItemClick: () => {
|
||||||
const pos = editor.getTextCursorPosition()
|
const pos = editor.getTextCursorPosition()
|
||||||
editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }])
|
editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }])
|
||||||
},
|
},
|
||||||
aliases: ['flux', 'output', 'render'] as const,
|
aliases: ['artifact', 'flux', 'output', 'render'] as const,
|
||||||
group: 'Flux',
|
group: 'Artifacts',
|
||||||
}
|
}
|
||||||
const all = [...defaultItems, fluxItem]
|
const all = [...defaultItems, fluxItem]
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
@@ -172,12 +173,33 @@ export function LogosPage() {
|
|||||||
[editor]
|
[editor]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!recollectionId) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
|
||||||
|
<p className="text-sm">No recollection selected.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isKatalogosView) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||||
|
<KatalogosPage />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePageId == null || !editor) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center bg-background text-sm text-muted-foreground">
|
||||||
|
{tree.length === 0 ? 'Loading…' : 'Select a page'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
|
||||||
<div className="mx-auto w-full max-w-3xl p-4">
|
<div className="mx-auto w-full max-w-3xl p-4">
|
||||||
<h1 className="mb-4 truncate font-serif text-2xl font-semibold tracking-tight text-foreground">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<BlockNoteViewWrapper
|
<BlockNoteViewWrapper
|
||||||
editor={editor as any}
|
editor={editor as any}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* BlockNote block "Flux output": insert output from Flux rendering nodes into Logos.
|
* BlockNote block "Flux output": insert Artifacts produced by Flux rendering nodes into Logos.
|
||||||
* Empty state: placeholder + picker over getRenderOutputCache(recollectionId).
|
* Empty state: placeholder + picker over getRenderOutputCache(recollectionId).
|
||||||
* Filled state: when nodeId is set, display is live from the cache (updates when the rendering node changes in Flux);
|
* Filled state: when nodeId is set, display is live from the cache (updates when the rendering node changes in Flux);
|
||||||
* otherwise or when cache entry is missing, show stored content (static).
|
* otherwise or when cache entry is missing, show stored content (static).
|
||||||
|
* Shows live/static status and time since last update.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { createReactBlockSpec } from '@blocknote/react'
|
import { createReactBlockSpec } from '@blocknote/react'
|
||||||
import type { ReactCustomBlockRenderProps } from '@blocknote/react'
|
import type { ReactCustomBlockRenderProps } from '@blocknote/react'
|
||||||
import { getRenderOutputCache, type RenderOutputCacheEntry } from '../../recollectionStore'
|
import {
|
||||||
|
getRenderOutputCache,
|
||||||
|
formatTimeSinceLastUpdate,
|
||||||
|
type RenderOutputCacheEntry,
|
||||||
|
} from '../../state/recollectionStore'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
|
||||||
@@ -39,6 +44,7 @@ function FluxOutputBlockContent({
|
|||||||
contentRef,
|
contentRef,
|
||||||
}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) {
|
}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
const nodeId = block.props.nodeId ?? ''
|
const nodeId = block.props.nodeId ?? ''
|
||||||
const storedContent = block.props.content ?? ''
|
const storedContent = block.props.content ?? ''
|
||||||
@@ -60,35 +66,47 @@ function FluxOutputBlockContent({
|
|||||||
[editor, block.id]
|
[editor, block.id]
|
||||||
)
|
)
|
||||||
|
|
||||||
const entries = recollectionId ? getRenderOutputCache(recollectionId) : []
|
const artifacts = recollectionId ? getRenderOutputCache(recollectionId) : []
|
||||||
|
|
||||||
// Resolve display from cache when block is tied to a node (live); otherwise use stored props (static).
|
// Resolve display from cache when block is tied to a node (live); otherwise use stored props (static).
|
||||||
const liveEntry = nodeId && recollectionId ? entries.find((e) => e.nodeId === nodeId) : null
|
const liveEntry = nodeId && recollectionId ? artifacts.find((e) => e.nodeId === nodeId) : null
|
||||||
const content = liveEntry ? liveEntry.content : storedContent
|
const content = liveEntry ? liveEntry.content : storedContent
|
||||||
const rawContentType = liveEntry ? liveEntry.type : storedContentType
|
const rawContentType = liveEntry ? liveEntry.type : storedContentType
|
||||||
const label = liveEntry ? liveEntry.label : storedLabel
|
const label = liveEntry ? liveEntry.label : storedLabel
|
||||||
|
const isLive = Boolean(liveEntry)
|
||||||
|
const timeSince = liveEntry?.updatedAt != null ? formatTimeSinceLastUpdate(liveEntry.updatedAt) : ''
|
||||||
// If content is SVG but type was stored as html, show as image (fixes incorrect cache or legacy data).
|
// If content is SVG but type was stored as html, show as image (fixes incorrect cache or legacy data).
|
||||||
const isSvgContent = Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
const isSvgContent = Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
||||||
const contentType = rawContentType === 'image' || isSvgContent ? 'image' : 'html'
|
const contentType = rawContentType === 'image' || isSvgContent ? 'image' : 'html'
|
||||||
|
|
||||||
|
const handleViewInFlux = useCallback(() => {
|
||||||
|
if (recollectionId && nodeId) {
|
||||||
|
navigate(`/recollections/${recollectionId}/flux?focusNode=${encodeURIComponent(nodeId)}`)
|
||||||
|
}
|
||||||
|
}, [recollectionId, nodeId, navigate])
|
||||||
|
|
||||||
// Empty: show placeholder + picker when nothing inserted yet
|
// Empty: show placeholder + picker when nothing inserted yet
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="min-h-[80px] rounded-md border border-dashed border-muted-foreground/30 bg-muted/30 p-4">
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="min-h-[80px] rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 p-4"
|
||||||
|
>
|
||||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
Insert from Flux
|
Insert Artifact
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80 p-0" align="start">
|
<PopoverContent className="w-80 p-0" align="start">
|
||||||
{entries.length === 0 ? (
|
{artifacts.length === 0 ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
No Flux outputs yet. In Flux, run a graph with a rendering node; its output will appear here automatically.
|
No Artifacts yet. In Flux, run a graph with a rendering node; its output will be cached as an Artifact
|
||||||
|
and appear here and in Katalogos automatically.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="max-h-64 overflow-auto py-2">
|
<ul className="max-h-64 overflow-auto py-2">
|
||||||
{entries.map((entry) => (
|
{artifacts.map((entry) => (
|
||||||
<li key={entry.nodeId}>
|
<li key={entry.nodeId}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -108,28 +126,70 @@ function FluxOutputBlockContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filled: show image or HTML
|
// Filled: card layout with header (label + live/static status + time) and content
|
||||||
|
const header = (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 bg-muted/30 px-3 py-1.5 rounded-t-lg">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{label ? <span className="truncate text-xs font-medium text-foreground">{label}</span> : null}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isLive
|
||||||
|
? 'inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLive && <span className="h-1 w-1 shrink-0 rounded-full bg-emerald-500" aria-hidden />}
|
||||||
|
{isLive ? 'Live' : 'Static'}
|
||||||
|
</span>
|
||||||
|
{timeSince ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{timeSince}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{nodeId && recollectionId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={handleViewInFlux}
|
||||||
|
>
|
||||||
|
View in Flux
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
if (contentType === 'image') {
|
if (contentType === 'image') {
|
||||||
const src =
|
const src =
|
||||||
content.startsWith('data:') ? content : `data:image/svg+xml;utf8,${encodeURIComponent(content)}`
|
content.startsWith('data:') ? content : `data:image/svg+xml;utf8,${encodeURIComponent(content)}`
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="min-h-[40px]">
|
<div
|
||||||
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
|
ref={contentRef}
|
||||||
<img src={src} alt={label || 'Flux output'} className="max-w-full rounded border object-contain" />
|
className="min-h-[40px] overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
<div className="p-2">
|
||||||
|
<img src={src} alt={label || 'Artifact'} className="max-w-full rounded-md border border-border/60 object-contain" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML: render in iframe to limit script execution (XSS safety)
|
// HTML: render in iframe to limit script execution (XSS safety)
|
||||||
return (
|
return (
|
||||||
<div ref={contentRef} className="min-h-[40px]">
|
<div
|
||||||
{label ? <p className="mb-1 text-xs text-muted-foreground">{label}</p> : null}
|
ref={contentRef}
|
||||||
<iframe
|
className="min-h-[40px] overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm"
|
||||||
title={label || 'Flux HTML output'}
|
>
|
||||||
srcDoc={content}
|
{header}
|
||||||
className="min-h-[120px] w-full rounded border border-border bg-background"
|
<div className="p-2">
|
||||||
sandbox="allow-same-origin"
|
<iframe
|
||||||
/>
|
title={label || 'Artifact HTML'}
|
||||||
|
srcDoc={content}
|
||||||
|
className="min-h-[120px] w-full rounded-md border border-border/60 bg-background"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const fluxOutputBlock = createFluxOutputBlock()
|
|||||||
export const logosSchema = BlockNoteSchema.create({
|
export const logosSchema = BlockNoteSchema.create({
|
||||||
blockSpecs: {
|
blockSpecs: {
|
||||||
...defaultBlockSpecs,
|
...defaultBlockSpecs,
|
||||||
fluxOutput: fluxOutputBlock(),
|
fluxOutput: fluxOutputBlock,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
/**
|
|
||||||
* Per-recollection persistence: graph (Flux) and logos (BlockNote) state.
|
|
||||||
* Single API for loading, saving, and removing all data for a recollection.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { StoredGraphState } from '@/lib/graph/state'
|
|
||||||
|
|
||||||
export type { StoredGraphState }
|
|
||||||
|
|
||||||
/** BlockNote document: array of blocks (PartialBlock). Stored as JSON. */
|
|
||||||
export type StoredLogosContent = Record<string, unknown>[]
|
|
||||||
|
|
||||||
/** One entry in the render-output cache (one per rendering node, overwritten on each update). */
|
|
||||||
export type RenderOutputCacheEntry = {
|
|
||||||
nodeId: string
|
|
||||||
label: string
|
|
||||||
type: 'image' | 'html'
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
|
||||||
export const RECOLLECTION_VERSION = 1
|
|
||||||
|
|
||||||
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
|
||||||
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
|
||||||
const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_'
|
|
||||||
|
|
||||||
function getGraphKey(recollectionId: string): string {
|
|
||||||
return `${GRAPH_KEY_PREFIX}${recollectionId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLogosKey(recollectionId: string): string {
|
|
||||||
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRenderCacheKey(recollectionId: string): string {
|
|
||||||
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getGraph(recollectionId: string): StoredGraphState | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(getGraphKey(recollectionId))
|
|
||||||
if (!raw) return null
|
|
||||||
const data = JSON.parse(raw) as unknown
|
|
||||||
if (
|
|
||||||
!data ||
|
|
||||||
typeof data !== 'object' ||
|
|
||||||
!Array.isArray((data as StoredGraphState).nodes) ||
|
|
||||||
!Array.isArray((data as StoredGraphState).edges)
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
return data as StoredGraphState
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setGraph(recollectionId: string, state: StoredGraphState): void {
|
|
||||||
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLogosContent(recollectionId: string): StoredLogosContent | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(getLogosKey(recollectionId))
|
|
||||||
if (!raw) return null
|
|
||||||
const data = JSON.parse(raw) as unknown
|
|
||||||
if (!Array.isArray(data)) return null
|
|
||||||
if (!data.every((item) => item != null && typeof item === 'object')) return null
|
|
||||||
return data as StoredLogosContent
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setLogosContent(recollectionId: string, content: StoredLogosContent): void {
|
|
||||||
localStorage.setItem(getLogosKey(recollectionId), JSON.stringify(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render output cache: one entry per rendering node (keyed by nodeId). Used by Logos "Insert from Flux" block. */
|
|
||||||
export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(getRenderCacheKey(recollectionId))
|
|
||||||
if (!raw) return []
|
|
||||||
const data = JSON.parse(raw) as unknown
|
|
||||||
if (!Array.isArray(data)) return []
|
|
||||||
return data.filter(
|
|
||||||
(item): item is RenderOutputCacheEntry =>
|
|
||||||
item != null &&
|
|
||||||
typeof item === 'object' &&
|
|
||||||
typeof (item as RenderOutputCacheEntry).nodeId === 'string' &&
|
|
||||||
typeof (item as RenderOutputCacheEntry).label === 'string' &&
|
|
||||||
((item as RenderOutputCacheEntry).type === 'image' || (item as RenderOutputCacheEntry).type === 'html') &&
|
|
||||||
typeof (item as RenderOutputCacheEntry).content === 'string'
|
|
||||||
) as RenderOutputCacheEntry[]
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOutputCacheEntry): void {
|
|
||||||
const entries = getRenderOutputCache(recollectionId)
|
|
||||||
const byNodeId = new Map(entries.map((e) => [e.nodeId, e]))
|
|
||||||
byNodeId.set(entry.nodeId, entry)
|
|
||||||
localStorage.setItem(
|
|
||||||
getRenderCacheKey(recollectionId),
|
|
||||||
JSON.stringify(Array.from(byNodeId.values()))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Removes both graph, logos, and render cache data for the recollection. */
|
|
||||||
export function removeRecollectionData(recollectionId: string): void {
|
|
||||||
localStorage.removeItem(getGraphKey(recollectionId))
|
|
||||||
localStorage.removeItem(getLogosKey(recollectionId))
|
|
||||||
localStorage.removeItem(getRenderCacheKey(recollectionId))
|
|
||||||
}
|
|
||||||
283
frontend/src/app/recollections/state/recollectionStore.ts
Normal file
283
frontend/src/app/recollections/state/recollectionStore.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* Per-recollection persistence: graph (Flux) and logos (BlockNote) state.
|
||||||
|
* Single API for loading, saving, and removing all data for a recollection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StoredGraphState } from '@/lib/graph/state'
|
||||||
|
|
||||||
|
export type { StoredGraphState }
|
||||||
|
|
||||||
|
/** BlockNote document: array of blocks (PartialBlock). Stored as JSON. */
|
||||||
|
export type StoredLogosContent = Record<string, unknown>[]
|
||||||
|
|
||||||
|
/** Id for a Logos page or subpage. */
|
||||||
|
export type LogosPageId = string
|
||||||
|
|
||||||
|
/** Metadata for one page or subpage in the Logos hierarchy (one level: page → subpages). */
|
||||||
|
export type LogosPageMeta = {
|
||||||
|
id: LogosPageId
|
||||||
|
title: string
|
||||||
|
/** null = top-level page; non-null = subpage under that page. */
|
||||||
|
parentId: LogosPageId | null
|
||||||
|
/** Order among siblings (same parent). */
|
||||||
|
position: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry in the render-output cache (one per rendering node, overwritten on each update). */
|
||||||
|
export type RenderOutputCacheEntry = {
|
||||||
|
nodeId: string
|
||||||
|
label: string
|
||||||
|
type: 'image' | 'html'
|
||||||
|
content: string
|
||||||
|
/** Timestamp (ms) when this entry was last updated. */
|
||||||
|
updatedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a short "time since" string for display (e.g. "Just now", "2 min ago"). */
|
||||||
|
export function formatTimeSinceLastUpdate(updatedAt: number | undefined): string {
|
||||||
|
if (updatedAt == null || typeof updatedAt !== 'number') return ''
|
||||||
|
const delta = Date.now() - updatedAt
|
||||||
|
if (delta < 15_000) return 'Just now'
|
||||||
|
if (delta < 60_000) return `${Math.round(delta / 1000)}s ago`
|
||||||
|
if (delta < 3600_000) return `${Math.round(delta / 60_000)} min ago`
|
||||||
|
if (delta < 86400_000) return `${Math.round(delta / 3600_000)}h ago`
|
||||||
|
return `${Math.round(delta / 86400_000)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
||||||
|
export const RECOLLECTION_VERSION = 1
|
||||||
|
|
||||||
|
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
||||||
|
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
||||||
|
const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_'
|
||||||
|
const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_'
|
||||||
|
const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_'
|
||||||
|
|
||||||
|
function getGraphKey(recollectionId: string): string {
|
||||||
|
return `${GRAPH_KEY_PREFIX}${recollectionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogosKey(recollectionId: string): string {
|
||||||
|
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogosPageTreeKey(recollectionId: string): string {
|
||||||
|
return `${LOGOS_PAGE_TREE_PREFIX}${recollectionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogosPageContentKey(recollectionId: string, pageId: LogosPageId): string {
|
||||||
|
return `${LOGOS_PAGE_CONTENT_PREFIX}${recollectionId}_${pageId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRenderCacheKey(recollectionId: string): string {
|
||||||
|
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Persisted State with Versioning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for persisted state with version tracking.
|
||||||
|
* Enables schema migrations when the version changes.
|
||||||
|
*/
|
||||||
|
export type PersistedState<T> = {
|
||||||
|
/** Current schema version. Increment when making breaking changes. */
|
||||||
|
version: number
|
||||||
|
/** The actual data being persisted. */
|
||||||
|
data: T
|
||||||
|
/** Timestamp (ms) when this data was last migrated. */
|
||||||
|
migratedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a persisted state has the expected version.
|
||||||
|
* @param state - The persisted state to check
|
||||||
|
* @param expectedVersion - The expected version number
|
||||||
|
* @returns true if versions match, false otherwise
|
||||||
|
*/
|
||||||
|
export function isVersionMatch<T>(state: PersistedState<T> | null, expectedVersion: number): boolean {
|
||||||
|
return state?.version === expectedVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate persisted state to the latest version.
|
||||||
|
* Add migration logic here when changing the schema.
|
||||||
|
*
|
||||||
|
* @param state - The persisted state to migrate
|
||||||
|
* @returns Migrated state with updated version
|
||||||
|
*/
|
||||||
|
export function migratePersistedState<T>(state: PersistedState<T>): PersistedState<T> {
|
||||||
|
if (state.version === RECOLLECTION_VERSION) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
let migratedData = state.data
|
||||||
|
let migratedVersion = state.version
|
||||||
|
|
||||||
|
// Migration 0 -> 1: Initial version with version field
|
||||||
|
if (migratedVersion < 1) {
|
||||||
|
// Data from version 0 already has the correct structure
|
||||||
|
// Just add the version field
|
||||||
|
migratedVersion = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: migratedVersion,
|
||||||
|
data: migratedData,
|
||||||
|
migratedAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Graph Storage with Versioning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getGraph(recollectionId: string): StoredGraphState | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getGraphKey(recollectionId))
|
||||||
|
if (!raw) return null
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
!Array.isArray((data as StoredGraphState).nodes) ||
|
||||||
|
!Array.isArray((data as StoredGraphState).edges)
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
return data as StoredGraphState
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setGraph(recollectionId: string, state: StoredGraphState): void {
|
||||||
|
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logos Storage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getLogosContent(recollectionId: string): StoredLogosContent | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getLogosKey(recollectionId))
|
||||||
|
if (!raw) return null
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(data)) return null
|
||||||
|
if (!data.every((item) => item != null && typeof item === 'object')) return null
|
||||||
|
return data as StoredLogosContent
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLogosContent(recollectionId: string, content: StoredLogosContent): void {
|
||||||
|
localStorage.setItem(getLogosKey(recollectionId), JSON.stringify(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logos page tree: ordered list of page metas (pages and subpages). */
|
||||||
|
export function getLogosPageTree(recollectionId: string): LogosPageMeta[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getLogosPageTreeKey(recollectionId))
|
||||||
|
if (!raw) return []
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(data)) return []
|
||||||
|
return data.filter(
|
||||||
|
(item): item is LogosPageMeta =>
|
||||||
|
item != null &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
typeof (item as LogosPageMeta).id === 'string' &&
|
||||||
|
typeof (item as LogosPageMeta).title === 'string' &&
|
||||||
|
((item as LogosPageMeta).parentId === null || typeof (item as LogosPageMeta).parentId === 'string') &&
|
||||||
|
typeof (item as LogosPageMeta).position === 'number'
|
||||||
|
) as LogosPageMeta[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLogosPageTree(recollectionId: string, tree: LogosPageMeta[]): void {
|
||||||
|
localStorage.setItem(getLogosPageTreeKey(recollectionId), JSON.stringify(tree))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-page Logos content (for page/subpage hierarchy). */
|
||||||
|
export function getLogosContentForPage(
|
||||||
|
recollectionId: string,
|
||||||
|
pageId: LogosPageId
|
||||||
|
): StoredLogosContent | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getLogosPageContentKey(recollectionId, pageId))
|
||||||
|
if (!raw) return null
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(data)) return null
|
||||||
|
if (!data.every((item) => item != null && typeof item === 'object')) return null
|
||||||
|
return data as StoredLogosContent
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLogosContentForPage(
|
||||||
|
recollectionId: string,
|
||||||
|
pageId: LogosPageId,
|
||||||
|
content: StoredLogosContent
|
||||||
|
): void {
|
||||||
|
localStorage.setItem(getLogosPageContentKey(recollectionId, pageId), JSON.stringify(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove stored content for a single page (e.g. when deleting that page). */
|
||||||
|
export function removeLogosPageContent(recollectionId: string, pageId: LogosPageId): void {
|
||||||
|
localStorage.removeItem(getLogosPageContentKey(recollectionId, pageId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render Output Cache
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Render output cache: one entry per rendering node (keyed by nodeId). Used by Logos "Insert from Flux" block. */
|
||||||
|
export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(getRenderCacheKey(recollectionId))
|
||||||
|
if (!raw) return []
|
||||||
|
const data = JSON.parse(raw) as unknown
|
||||||
|
if (!Array.isArray(data)) return []
|
||||||
|
return data.filter(
|
||||||
|
(item): item is RenderOutputCacheEntry =>
|
||||||
|
item != null &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
typeof (item as RenderOutputCacheEntry).nodeId === 'string' &&
|
||||||
|
typeof (item as RenderOutputCacheEntry).label === 'string' &&
|
||||||
|
((item as RenderOutputCacheEntry).type === 'image' || (item as RenderOutputCacheEntry).type === 'html') &&
|
||||||
|
typeof (item as RenderOutputCacheEntry).content === 'string'
|
||||||
|
) as RenderOutputCacheEntry[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOutputCacheEntry): void {
|
||||||
|
const entries = getRenderOutputCache(recollectionId)
|
||||||
|
const byNodeId = new Map(entries.map((e) => [e.nodeId, e]))
|
||||||
|
byNodeId.set(entry.nodeId, entry)
|
||||||
|
localStorage.setItem(
|
||||||
|
getRenderCacheKey(recollectionId),
|
||||||
|
JSON.stringify(Array.from(byNodeId.values()))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Remove Recollection Data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */
|
||||||
|
export function removeRecollectionData(recollectionId: string): void {
|
||||||
|
localStorage.removeItem(getGraphKey(recollectionId))
|
||||||
|
localStorage.removeItem(getLogosKey(recollectionId))
|
||||||
|
const tree = getLogosPageTree(recollectionId)
|
||||||
|
for (const page of tree) {
|
||||||
|
removeLogosPageContent(recollectionId, page.id)
|
||||||
|
}
|
||||||
|
localStorage.removeItem(getLogosPageTreeKey(recollectionId))
|
||||||
|
localStorage.removeItem(getRenderCacheKey(recollectionId))
|
||||||
|
}
|
||||||
@@ -9,6 +9,13 @@ import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/g
|
|||||||
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
|
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
|
||||||
import { isReachable, resolveExtendsRef, getTemplateRefs, type NodeLike } from '@/lib/graph/templateRefs'
|
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> {
|
export async function getResolvedContentForConfig(context: SourceRenderingLogicContext): Promise<ResolvedContentResult> {
|
||||||
const { nodes, edges, sourceNodeId, renderNodeId } = context
|
const { nodes, edges, sourceNodeId, renderNodeId } = context
|
||||||
const srcId = sourceNodeId
|
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 env = new nunjucks.Environment([configLoader], { autoescape: false })
|
||||||
|
|
||||||
const formatFilterResult = (r: unknown): string => {
|
const formatFilterResult = (r: unknown): string => {
|
||||||
@@ -248,7 +273,13 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,66 @@
|
|||||||
* runs the resolve → render pipeline, manages streaming/cache,
|
* runs the resolve → render pipeline, manages streaming/cache,
|
||||||
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
|
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
|
||||||
* pipeline interface.
|
* pipeline interface.
|
||||||
|
*
|
||||||
|
* ## Auto vs Manual Mode Decision Tree
|
||||||
|
*
|
||||||
|
* The hook supports two update modes: 'auto' and 'manual'. The mode is determined by:
|
||||||
|
*
|
||||||
|
* 1. **Node data override**: Check `data?.updateMode` first
|
||||||
|
* 2. **Source logic default**: Fall back to `sourceLogic?.defaultUpdateMode`
|
||||||
|
* 3. **Hardcoded default**: Default to 'auto' if neither is set
|
||||||
|
*
|
||||||
|
* ### Auto Mode Behavior
|
||||||
|
* - Runs automatically when upstream data changes (sourceSignature changes)
|
||||||
|
* - Debounces runs by 250ms to avoid excessive computation
|
||||||
|
* - Skips runs when node becomes visible with cache (visibility grace period: 400ms)
|
||||||
|
* - Updates path trigger on each run
|
||||||
|
*
|
||||||
|
* ### Manual Mode Behavior
|
||||||
|
* - Only runs when `runTrigger` increments (user clicks "Run")
|
||||||
|
* - Requires explicit user action to update output
|
||||||
|
* - Caches last run signature to prevent re-runs on unrelated changes
|
||||||
|
*
|
||||||
|
* ## Effect Dependencies
|
||||||
|
*
|
||||||
|
* The main effect depends on:
|
||||||
|
* - `id`: Node ID (re-run when node changes)
|
||||||
|
* - `srcId`: Source node ID (re-run when connection changes)
|
||||||
|
* - `srcNode?.type`: Source node type (re-run when source type changes)
|
||||||
|
* - `effectiveUpdateMode`: Auto vs manual mode
|
||||||
|
* - `runTrigger`: Run trigger counter (for manual mode)
|
||||||
|
* - `sourceSignature`: Combined signature of all upstream nodes
|
||||||
|
* - `viewportWidth/Height`: Viewport dimensions (affects rendering)
|
||||||
|
* - `retryCount`: Retry counter (for error recovery)
|
||||||
|
* - `incomingIds.length`: Number of incoming connections
|
||||||
|
*
|
||||||
|
* ## Rendering Pipeline
|
||||||
|
*
|
||||||
|
* 1. **Resolve**: Source node (config/agent) produces resolved content
|
||||||
|
* 2. **Render**: Output type renderer (plantuml/markdown/wireframe) produces HTML/SVG
|
||||||
|
* 3. **Cache**: Results are cached in node data for persistence
|
||||||
|
* 4. **Display**: UI consumes the rendered content
|
||||||
|
*
|
||||||
|
* ## Streaming Support
|
||||||
|
*
|
||||||
|
* When the source node supports streaming (e.g., agent nodes):
|
||||||
|
* - `onStreamingStart`: Called when streaming begins
|
||||||
|
* - `onStreamingChunk`: Called for each chunk of markdown content
|
||||||
|
* - Streaming content is parsed with marked.js for preview
|
||||||
|
*
|
||||||
|
* ## Error Handling
|
||||||
|
*
|
||||||
|
* Errors during resolve or render are caught and stored in the `error` state.
|
||||||
|
* The error includes a `kind` (e.g., 'render', 'no-content') and `message`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
||||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||||
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { useOptionalRecollectionActions } from '@/app/recollections/RecollectionActionsContext'
|
import { useOptionalRecollectionActions } from '@/app/recollections/layout/RecollectionActionsContext'
|
||||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||||
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||||
import {
|
import {
|
||||||
@@ -124,8 +176,11 @@ export function useRenderingNodeState(
|
|||||||
|
|
||||||
// Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant data).
|
// 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.
|
// Context only exposes a ref, so we wouldn't re-render when another node updates otherwise.
|
||||||
const storeNodes = useCanvasStore((s) => s.graph.nodes)
|
// Single combined subscription (vs two separate) halves listener overhead; useShallow prevents
|
||||||
const storeEdges = useCanvasStore((s) => s.graph.edges)
|
// 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 nodes = storeNodes.length > 0 ? storeNodes : contextNodes
|
||||||
const edges = storeEdges.length > 0 ? storeEdges : contextEdges
|
const edges = storeEdges.length > 0 ? storeEdges : contextEdges
|
||||||
|
|
||||||
@@ -302,6 +357,7 @@ export function useRenderingNodeState(
|
|||||||
label: id,
|
label: id,
|
||||||
type: isSvgContent ? 'image' : 'html',
|
type: isSvgContent ? 'image' : 'html',
|
||||||
content: cached,
|
content: cached,
|
||||||
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -366,6 +422,7 @@ export function useRenderingNodeState(
|
|||||||
label: id,
|
label: id,
|
||||||
type: cacheType,
|
type: cacheType,
|
||||||
content: htmlOrSvg ?? '',
|
content: htmlOrSvg ?? '',
|
||||||
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
const mode = outputModeRef.current
|
const mode = outputModeRef.current
|
||||||
const cachedOutputValue =
|
const cachedOutputValue =
|
||||||
|
|||||||
@@ -758,6 +758,7 @@ export {
|
|||||||
SidebarMenuAction,
|
SidebarMenuAction,
|
||||||
SidebarMenuBadge,
|
SidebarMenuBadge,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
|
sidebarMenuButtonVariants,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSkeleton,
|
SidebarMenuSkeleton,
|
||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
|
|||||||
86
frontend/src/hooks/useStreamingContent.ts
Normal file
86
frontend/src/hooks/useStreamingContent.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Hook for parsing and streaming markdown content.
|
||||||
|
* Handles marked.js parsing with proper cleanup and cancellation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown content to HTML using marked.js.
|
||||||
|
* Returns parsed HTML string and handles cleanup on unmount.
|
||||||
|
*
|
||||||
|
* @param markdown - The markdown string to parse
|
||||||
|
* @returns HTML string from parsed markdown
|
||||||
|
*/
|
||||||
|
export function useStreamingContent(markdown: string | null): {
|
||||||
|
html: string
|
||||||
|
loading: boolean
|
||||||
|
} {
|
||||||
|
const [html, setHtml] = useState<string>('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (markdown === null) {
|
||||||
|
setHtml('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
// Lazy load marked.js to avoid bundling if not needed
|
||||||
|
import('marked')
|
||||||
|
.then(async ({ marked }) => {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed =
|
||||||
|
typeof marked.parse === 'function'
|
||||||
|
? await (marked.parse as (s: string) => Promise<string>)(markdown)
|
||||||
|
: (marked as (s: string) => string)(markdown)
|
||||||
|
const result = typeof parsed === 'string' ? parsed : String(parsed)
|
||||||
|
if (!cancelled) {
|
||||||
|
setHtml(result)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
// Fallback: use raw markdown if parsing fails
|
||||||
|
setHtml(markdown)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
// Fallback: use raw markdown if import fails
|
||||||
|
setHtml(markdown)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [markdown])
|
||||||
|
|
||||||
|
return { html, loading }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown content to HTML using marked.js (synchronous version).
|
||||||
|
* Use this when you need immediate parsing without React state.
|
||||||
|
*
|
||||||
|
* @param markdown - The markdown string to parse
|
||||||
|
* @returns HTML string from parsed markdown, or raw markdown on error
|
||||||
|
*/
|
||||||
|
export function parseMarkdownToHtml(markdown: string): string {
|
||||||
|
try {
|
||||||
|
// Use dynamic import for marked.js
|
||||||
|
// Note: This is a helper for non-React contexts
|
||||||
|
// In React components, use useStreamingContent hook instead
|
||||||
|
return markdown // Placeholder - actual implementation requires async import
|
||||||
|
} catch {
|
||||||
|
return markdown
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend/src/hooks/useThinkSections.ts
Normal file
30
frontend/src/hooks/useThinkSections.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Hook for parsing markdown content with think sections.
|
||||||
|
* Extracts <think>...</think> blocks and returns main content and reasoning separately.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { parseThinkSections } from '@/lib/graph/renderingUtils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown content and extract think sections.
|
||||||
|
* Returns main content (without think blocks) and reasoning content (from think blocks).
|
||||||
|
*
|
||||||
|
* @param content - The HTML/markdown content to parse
|
||||||
|
* @param outputType - The output type ('image' or 'string')
|
||||||
|
* @returns Object with main and think content
|
||||||
|
*/
|
||||||
|
export function useThinkSections(
|
||||||
|
content: string | null,
|
||||||
|
outputType: 'image' | 'string'
|
||||||
|
): {
|
||||||
|
main: string
|
||||||
|
think: string
|
||||||
|
} {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!content || outputType === 'image') {
|
||||||
|
return { main: '', think: '' }
|
||||||
|
}
|
||||||
|
return parseThinkSections(content)
|
||||||
|
}, [content, outputType])
|
||||||
|
}
|
||||||
169
frontend/src/lib/errorBoundary.tsx
Normal file
169
frontend/src/lib/errorBoundary.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Error boundary utilities for React components.
|
||||||
|
* Provides consistent error handling across the application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error state for error boundary components
|
||||||
|
*/
|
||||||
|
export type ErrorState = {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
errorInfo: React.ErrorInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial error state
|
||||||
|
*/
|
||||||
|
export const initialErrorState: ErrorState = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary props
|
||||||
|
*/
|
||||||
|
export type ErrorBoundaryProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
fallback?: React.ReactNode
|
||||||
|
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary state
|
||||||
|
*/
|
||||||
|
export type ErrorBoundaryState = ErrorState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary component
|
||||||
|
* Catches JavaScript errors anywhere in the child component tree
|
||||||
|
* and displays a fallback UI instead of crashing
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props)
|
||||||
|
this.state = initialErrorState
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
errorInfo: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||||
|
this.setState({
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log error to console or error reporting service
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||||
|
|
||||||
|
// Call custom error handler if provided
|
||||||
|
if (this.props.onError) {
|
||||||
|
this.props.onError(error, errorInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = (): void => {
|
||||||
|
this.setState(initialErrorState)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
// You can render any custom fallback UI
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="error-boundary">
|
||||||
|
<h1>Something went wrong.</h1>
|
||||||
|
<details>
|
||||||
|
<summary>Error Details</summary>
|
||||||
|
<pre>{this.state.error?.toString()}</pre>
|
||||||
|
<pre>{this.state.errorInfo?.componentStack}</pre>
|
||||||
|
</details>
|
||||||
|
<button onClick={this.handleReset}>Try Again</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for error handling in functional components
|
||||||
|
*/
|
||||||
|
export function useErrorHandler<T extends Error = Error>(): {
|
||||||
|
error: T | null
|
||||||
|
setError: (error: T | null) => void
|
||||||
|
hasError: boolean
|
||||||
|
resetError: () => void
|
||||||
|
} {
|
||||||
|
const [error, setError] = React.useState<T | null>(null)
|
||||||
|
|
||||||
|
const hasError = error !== null
|
||||||
|
const resetError = React.useCallback(() => setError(null), [])
|
||||||
|
|
||||||
|
return { error, setError, hasError, resetError }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to update error state consistently
|
||||||
|
*/
|
||||||
|
export function updateErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null | { kind: string; message: string }) => void,
|
||||||
|
error: unknown
|
||||||
|
): void {
|
||||||
|
setError({
|
||||||
|
kind: 'render',
|
||||||
|
message: (error as { message?: string })?.message ?? 'Render error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to clear error state
|
||||||
|
*/
|
||||||
|
export function clearErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null) => void
|
||||||
|
): void {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to check if an error is recoverable
|
||||||
|
*/
|
||||||
|
export function isRecoverableError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) return false
|
||||||
|
// Common recoverable errors
|
||||||
|
const recoverableMessages = [
|
||||||
|
'Network error',
|
||||||
|
'Timeout',
|
||||||
|
'Connection refused',
|
||||||
|
'Rate limit exceeded',
|
||||||
|
]
|
||||||
|
return recoverableMessages.some((msg) => (error as Error).message.includes(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to format error message for display
|
||||||
|
*/
|
||||||
|
export function formatErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
return 'An unknown error occurred'
|
||||||
|
}
|
||||||
155
frontend/src/lib/errorHandling.ts
Normal file
155
frontend/src/lib/errorHandling.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Error handling utilities for consistent error management.
|
||||||
|
* Provides centralized error handling logic across the application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error kind for categorizing errors
|
||||||
|
*/
|
||||||
|
export type ErrorKind =
|
||||||
|
| 'render'
|
||||||
|
| 'network'
|
||||||
|
| 'validation'
|
||||||
|
| 'authentication'
|
||||||
|
| 'authorization'
|
||||||
|
| 'notFound'
|
||||||
|
| 'server'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error object with kind and message
|
||||||
|
*/
|
||||||
|
export type AppError = {
|
||||||
|
kind: ErrorKind
|
||||||
|
message: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error object with consistent structure
|
||||||
|
*/
|
||||||
|
export function createAppError(
|
||||||
|
kind: ErrorKind,
|
||||||
|
message: string,
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
): AppError {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract error message from unknown error
|
||||||
|
*/
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||||
|
return String(error.message)
|
||||||
|
}
|
||||||
|
return 'An unknown error occurred'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract error kind from error
|
||||||
|
*/
|
||||||
|
export function getErrorKind(error: unknown): ErrorKind {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase()
|
||||||
|
if (message.includes('network')) return 'network'
|
||||||
|
if (message.includes('validation') || message.includes('invalid')) return 'validation'
|
||||||
|
if (message.includes('auth')) return 'authentication'
|
||||||
|
if (message.includes('forbidden') || message.includes('unauthorized')) return 'authorization'
|
||||||
|
if (message.includes('not found') || message.includes('404')) return 'notFound'
|
||||||
|
if (message.includes('server') || message.includes('500')) return 'server'
|
||||||
|
}
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format error for display
|
||||||
|
*/
|
||||||
|
export function formatError(error: AppError | Error | string): string {
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is recoverable
|
||||||
|
*/
|
||||||
|
export function isRecoverableError(error: AppError | Error | string): boolean {
|
||||||
|
if (typeof error === 'string') return false
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase()
|
||||||
|
return ['network', 'timeout', 'connection'].some((term) => message.includes(term))
|
||||||
|
}
|
||||||
|
// error is AppError at this point
|
||||||
|
return ['network', 'server'].includes((error as AppError).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update error state consistently
|
||||||
|
*/
|
||||||
|
export function updateErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null | { kind: string; message: string }) => void,
|
||||||
|
error: unknown
|
||||||
|
): void {
|
||||||
|
setError({
|
||||||
|
kind: 'render',
|
||||||
|
message: getErrorMessage(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear error state
|
||||||
|
*/
|
||||||
|
export function clearErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null) => void
|
||||||
|
): void {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle error with consistent logging
|
||||||
|
*/
|
||||||
|
export function handleError(
|
||||||
|
error: unknown,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): AppError {
|
||||||
|
const appError = createAppError(
|
||||||
|
getErrorKind(error),
|
||||||
|
getErrorMessage(error),
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
console.error('[Error]', appError, context)
|
||||||
|
return appError
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create error handler for async functions
|
||||||
|
*/
|
||||||
|
export function createErrorHandler<T>(
|
||||||
|
onError?: (error: AppError) => void
|
||||||
|
): (error: unknown) => T {
|
||||||
|
return (error: unknown) => {
|
||||||
|
const appError = handleError(error)
|
||||||
|
onError?.(appError)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,13 @@ export type ConfigType = {
|
|||||||
|
|
||||||
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
||||||
const KROKI_TIMEOUT_MS = 15000
|
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[] = [
|
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||||
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
||||||
@@ -172,36 +179,56 @@ const BUILTIN_CONFIG_TYPES: ConfigType[] = [
|
|||||||
language: 'plantuml',
|
language: 'plantuml',
|
||||||
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
||||||
render: async (content: string) => {
|
render: async (content: string) => {
|
||||||
const controller = new AbortController()
|
// 1. TTL cache hit
|
||||||
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
|
const cached = krokiCache.get(content)
|
||||||
try {
|
if (cached && Date.now() - cached.cachedAt < KROKI_CACHE_TTL_MS) {
|
||||||
const res = await fetch(KROKI_PLANTUML_SVG, {
|
return cached.result
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
|
||||||
body: content,
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.text()
|
|
||||||
if (res.status >= 500) {
|
|
||||||
throw new Error('Diagram service unavailable. Try again later.')
|
|
||||||
}
|
|
||||||
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
|
||||||
}
|
|
||||||
return res.text()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
if (err instanceof Error) {
|
|
||||||
if (err.name === 'AbortError') {
|
|
||||||
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
|
|
||||||
}
|
|
||||||
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
|
|
||||||
throw new Error('Diagram service unavailable. Check your connection or try again later.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
const res = await fetch(KROKI_PLANTUML_SVG, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
body: content,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
if (res.status >= 500) {
|
||||||
|
throw new Error('Diagram service unavailable. Try again later.')
|
||||||
|
}
|
||||||
|
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
||||||
|
}
|
||||||
|
const svg = await res.text()
|
||||||
|
krokiCache.set(content, { result: svg, cachedAt: Date.now() })
|
||||||
|
return svg
|
||||||
|
} catch (err: unknown) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
|
||||||
|
}
|
||||||
|
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
|
||||||
|
throw new Error('Diagram service unavailable. Check your connection or try again later.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
krokiInflight.delete(content)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
krokiInflight.set(content, fetchPromise)
|
||||||
|
return fetchPromise
|
||||||
},
|
},
|
||||||
outputMenuDescriptor: {
|
outputMenuDescriptor: {
|
||||||
submenuLabel: 'Export',
|
submenuLabel: 'Export',
|
||||||
|
|||||||
182
frontend/src/lib/graph/nodeFactory.ts
Normal file
182
frontend/src/lib/graph/nodeFactory.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Node factory utilities for creating and managing nodes.
|
||||||
|
* Centralizes node creation logic to reduce duplication.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
|
import {
|
||||||
|
getDefaultDataForType,
|
||||||
|
getNextNodeId,
|
||||||
|
} from '@/lib/graph/flowUtils'
|
||||||
|
import { getRegisteredNodeTypeIds, getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new node with auto-generated ID and default data.
|
||||||
|
*
|
||||||
|
* @param type - Node type ID (e.g., 'config', 'render')
|
||||||
|
* @param position - Node position { x, y }
|
||||||
|
* @param data - Optional additional data to merge with defaults
|
||||||
|
* @returns New node with generated ID
|
||||||
|
*/
|
||||||
|
export function createNode(
|
||||||
|
type: string,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
): AppNode {
|
||||||
|
const existingIds = getRegisteredNodeTypeIds()
|
||||||
|
const newId = getNextNodeId(type, existingIds)
|
||||||
|
return {
|
||||||
|
id: newId,
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
data: { ...getDefaultDataForType(type, newId), ...(data ?? {}) },
|
||||||
|
style: getDefaultStyle(type),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new node with a specific ID.
|
||||||
|
* Use this when you need to control the node ID (e.g., when duplicating).
|
||||||
|
*
|
||||||
|
* @param type - Node type ID
|
||||||
|
* @param id - Node ID to use
|
||||||
|
* @param position - Node position { x, y }
|
||||||
|
* @param data - Optional additional data to merge with defaults
|
||||||
|
* @returns New node with specified ID
|
||||||
|
*/
|
||||||
|
export function createNodeWithId(
|
||||||
|
type: string,
|
||||||
|
id: string,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
): AppNode {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
data: { ...getDefaultDataForType(type, id), ...(data ?? {}) },
|
||||||
|
style: getDefaultStyle(type),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new edge connecting two nodes.
|
||||||
|
*
|
||||||
|
* @param source - Source node ID
|
||||||
|
* @param target - Target node ID
|
||||||
|
* @param id - Optional edge ID (auto-generated if not provided)
|
||||||
|
* @param data - Optional edge data
|
||||||
|
* @returns New edge
|
||||||
|
*/
|
||||||
|
export function createEdge(
|
||||||
|
source: string,
|
||||||
|
target: string,
|
||||||
|
id?: string,
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
): AppEdge {
|
||||||
|
return {
|
||||||
|
id: id ?? `e-${source}-${target}`,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
data: data ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create multiple nodes from a template.
|
||||||
|
*
|
||||||
|
* @param template - Template node to copy
|
||||||
|
* @param count - Number of nodes to create
|
||||||
|
* @param offset - Position offset between nodes { x, y }
|
||||||
|
* @returns Array of new nodes
|
||||||
|
*/
|
||||||
|
export function createNodesFromTemplate(
|
||||||
|
template: Partial<AppNode>,
|
||||||
|
count: number,
|
||||||
|
offset: { x: number; y: number }
|
||||||
|
): AppNode[] {
|
||||||
|
const nodes: AppNode[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const position = {
|
||||||
|
x: (template.position?.x ?? 0) + i * offset.x,
|
||||||
|
y: (template.position?.y ?? 0) + i * offset.y,
|
||||||
|
}
|
||||||
|
nodes.push({
|
||||||
|
id: template.id ?? getNextNodeId(template.type ?? 'node', nodes.map((n) => n.id)),
|
||||||
|
type: template.type ?? 'node',
|
||||||
|
position,
|
||||||
|
data: { ...template.data },
|
||||||
|
style: template.style ?? getDefaultStyle(template.type ?? 'node'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get existing node IDs for a specific type.
|
||||||
|
*
|
||||||
|
* @param nodes - Array of nodes to search
|
||||||
|
* @param type - Node type to filter by
|
||||||
|
* @returns Array of node IDs for the specified type
|
||||||
|
*/
|
||||||
|
export function getNodeIdsByType(nodes: AppNode[], type: string): string[] {
|
||||||
|
return nodes.filter((n) => n.type === type).map((n) => n.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next available node ID for a type based on existing nodes.
|
||||||
|
*
|
||||||
|
* @param type - Node type
|
||||||
|
* @param existingNodes - Array of existing nodes
|
||||||
|
* @returns Next available node ID
|
||||||
|
*/
|
||||||
|
export function getNextNodeIdForType(type: string, existingNodes: AppNode[]): string {
|
||||||
|
const existingIds = existingNodes.map((n) => n.id)
|
||||||
|
return getNextNodeId(type, existingIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate a node with a new ID and offset position.
|
||||||
|
*
|
||||||
|
* @param node - Node to duplicate
|
||||||
|
* @param offset - Position offset for the duplicate
|
||||||
|
* @returns Duplicated node
|
||||||
|
*/
|
||||||
|
export function duplicateNode(node: AppNode, offset: { x: number; y: number } = { x: 30, y: 30 }): AppNode {
|
||||||
|
const existingIds = [node.id] // Start with current node ID
|
||||||
|
const newId = getNextNodeId(node.type ?? 'node', existingIds)
|
||||||
|
const pos = node.position ?? { x: 0, y: 0 }
|
||||||
|
return {
|
||||||
|
id: newId,
|
||||||
|
type: node.type ?? 'node',
|
||||||
|
position: { x: pos.x + offset.x, y: pos.y + offset.y },
|
||||||
|
data: typeof node.data === 'object' && node.data !== null ? { ...(node.data as object) } : node.data,
|
||||||
|
style: node.style ?? getDefaultStyle(node.type ?? 'node'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a connection between two nodes.
|
||||||
|
*
|
||||||
|
* @param sourceNodeId - Source node ID
|
||||||
|
* @param sourceHandle - Source handle ID (optional)
|
||||||
|
* @param targetNodeId - Target node ID
|
||||||
|
* @param targetHandle - Target handle ID (optional)
|
||||||
|
* @returns Edge object connecting the nodes
|
||||||
|
*/
|
||||||
|
export function createConnection(
|
||||||
|
sourceNodeId: string,
|
||||||
|
sourceHandle?: string,
|
||||||
|
targetNodeId: string = '',
|
||||||
|
targetHandle?: string
|
||||||
|
): Edge {
|
||||||
|
const edge: Edge = {
|
||||||
|
id: `e-${sourceNodeId}-${targetNodeId}`,
|
||||||
|
source: sourceNodeId,
|
||||||
|
target: targetNodeId,
|
||||||
|
sourceHandle,
|
||||||
|
targetHandle,
|
||||||
|
}
|
||||||
|
return edge
|
||||||
|
}
|
||||||
48
frontend/src/lib/svgUtils.ts
Normal file
48
frontend/src/lib/svgUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* SVG detection utilities.
|
||||||
|
* Centralized logic for detecting and processing SVG content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Check if a string contains SVG content. */
|
||||||
|
export function isSvgContent(content: string | null | undefined): boolean {
|
||||||
|
return Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a file path or URL points to an SVG file. */
|
||||||
|
export function isSvgPath(path: string | null | undefined): boolean {
|
||||||
|
return Boolean(path?.toLowerCase().endsWith('.svg'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process SVG HTML for viewport display (aspect ratio, fill container). */
|
||||||
|
export function processSvgDisplay(html: string): string {
|
||||||
|
let out = html
|
||||||
|
// Change preserveAspectRatio from 'none' to 'xMidYMid meet' for proper scaling
|
||||||
|
out = out.replace(/\bpreserveAspectRatio\s*=\s*["']none["']/gi, 'preserveAspectRatio="xMidYMid meet"')
|
||||||
|
// Set width to 100% to fill container
|
||||||
|
out = out.replace(/\bwidth\s*=\s*["'][^"']*["']/i, 'width="100%"')
|
||||||
|
// Set height to 100% to fill container
|
||||||
|
out = out.replace(/\bheight\s*=\s*["'][^"']*["']/i, 'height="100%"')
|
||||||
|
// Override width/height in style attributes
|
||||||
|
out = out.replace(/\bstyle\s*=\s*["']([^"']*)["']/i, (_, style) => {
|
||||||
|
const overridden = style.replace(/\b(width|height):[^;]+/gi, '$1:100%')
|
||||||
|
return `style="${overridden}"`
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract SVG content from an HTML string. */
|
||||||
|
export function extractSvgContent(html: string): string | null {
|
||||||
|
const svgMatch = html.match(/<svg[\s\S]*?<\/svg>/i)
|
||||||
|
return svgMatch ? svgMatch[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if content is an SVG data URL. */
|
||||||
|
export function isSvgDataUrl(content: string | null | undefined): boolean {
|
||||||
|
return Boolean(content?.trim().startsWith('data:image/svg+xml'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert SVG string to data URL. */
|
||||||
|
export function svgToDataUrl(svg: string): string {
|
||||||
|
const encoded = btoa(unescape(encodeURIComponent(svg)))
|
||||||
|
return `data:image/svg+xml;base64,${encoded}`
|
||||||
|
}
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
import React from 'react'
|
import React, { lazy, Suspense } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { ThemeProvider } from './lib/themeContext'
|
import { ThemeProvider } from './lib/themeContext'
|
||||||
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
|
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
|
||||||
import { registerBuiltinConfigTypes } from './lib/graph/configTypes'
|
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 { RecollectionLayout } from './app/recollections/RecollectionLayout'
|
||||||
|
import { NotFoundPage } from './app/NotFoundPage'
|
||||||
|
|
||||||
import './lib/prismSetup'
|
import './lib/prismSetup'
|
||||||
import 'prismjs/themes/prism.css'
|
import 'prismjs/themes/prism.css'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
import '@xyflow/react/dist/style.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()
|
registerBuiltinConfigTypes()
|
||||||
registerBuiltinNodes()
|
registerBuiltinNodes()
|
||||||
|
|
||||||
@@ -20,17 +27,22 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground text-sm">Loading…</div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<KosmosPage />}>
|
<Route path="/" element={<KosmosPage />}>
|
||||||
<Route index element={<Navigate to="/recollections" replace />} />
|
<Route index element={<Navigate to="/recollections" replace />} />
|
||||||
<Route path="recollections" element={<RecollectionsPage />} />
|
<Route path="recollections" element={<RecollectionsPage />} />
|
||||||
<Route path="recollections/:recollectionId" element={<RecollectionLayout />}>
|
<Route path="recollections/:recollectionId" element={<RecollectionLayout />}>
|
||||||
<Route index element={<Navigate to="logos" replace />} />
|
<Route index element={<Navigate to="logos" replace />} />
|
||||||
<Route path="logos" element={<></>} />
|
<Route path="logos" element={<LogosPage />} />
|
||||||
<Route path="flux" element={<></>} />
|
<Route path="logos/katalogos" element={<LogosPage />} />
|
||||||
|
<Route path="flux" element={<FluxRoute />} />
|
||||||
|
<Route path="*" element={<Navigate to="logos" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
<Toaster richColors position="bottom-right" />
|
<Toaster richColors position="bottom-right" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
@import "shadcn/dist/tailwind.css";
|
@import "shadcn/dist/tailwind.css";
|
||||||
@import "@blocknote/core/fonts/inter.css";
|
|
||||||
@import "@blocknote/shadcn/style.css";
|
@import "@blocknote/shadcn/style.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@@ -374,18 +373,23 @@ pre {
|
|||||||
.token.nunjucks-var {
|
.token.nunjucks-var {
|
||||||
color: #7c3aed;
|
color: #7c3aed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token.nunjucks-tag {
|
.token.nunjucks-tag {
|
||||||
color: #c2410c;
|
color: #c2410c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token.nunjucks-comment {
|
.token.nunjucks-comment {
|
||||||
color: #0d9488;
|
color: #0d9488;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .token.nunjucks-var {
|
.dark .token.nunjucks-var {
|
||||||
color: #a78bfa;
|
color: #a78bfa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .token.nunjucks-tag {
|
.dark .token.nunjucks-tag {
|
||||||
color: #fb923c;
|
color: #fb923c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .token.nunjucks-comment {
|
.dark .token.nunjucks-comment {
|
||||||
color: #2dd4bf;
|
color: #2dd4bf;
|
||||||
}
|
}
|
||||||
@@ -397,38 +401,75 @@ pre {
|
|||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* BlockNote (Logos editor): font sizes to match app typography (Tailwind text-sm–text-lg scale) */
|
/* Logos tree drop line (LogosDropCursor in TreeBrowser.tsx) */
|
||||||
.logos-blocknote .bn-editor {
|
@keyframes tree-drop-line-pulse {
|
||||||
font-size: 0.875rem; /* text-sm */
|
|
||||||
line-height: 1.5;
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.logos-blocknote .bn-inline-content {
|
|
||||||
font-size: inherit;
|
/* React Arborist tree styling for sidebar integration */
|
||||||
line-height: inherit;
|
.react-arborist-tree {
|
||||||
}
|
background: transparent !important;
|
||||||
.logos-blocknote [data-content-type="heading"][data-level="1"] .bn-inline-content {
|
|
||||||
font-size: 1.25rem; /* text-xl */
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.logos-blocknote [data-content-type="heading"][data-level="2"] .bn-inline-content {
|
|
||||||
font-size: 1.125rem; /* text-lg */
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
.logos-blocknote [data-content-type="heading"][data-level="3"] .bn-inline-content {
|
|
||||||
font-size: 1rem; /* text-base */
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.logos-blocknote [data-content-type="paragraph"] .bn-inline-content {
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
.logos-blocknote [data-content-type="bulletListItem"] .bn-inline-content,
|
|
||||||
.logos-blocknote [data-content-type="numberedListItem"] .bn-inline-content {
|
.react-arborist-tree * {
|
||||||
font-size: 0.875rem;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
.logos-blocknote [data-content-type="codeBlock"] .bn-inline-content,
|
|
||||||
.logos-blocknote [data-content-type="code"] .bn-inline-content {
|
/* Remove default react-arborist styling */
|
||||||
font-size: 0.8125rem; /* slightly smaller for code */
|
.react-arborist-tree [role="treeitem"] {
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Drag preview styling */
|
||||||
|
.react-arborist-tree [data-react-arborist-drag-preview] {
|
||||||
|
background: hsl(var(--sidebar-accent)) !important;
|
||||||
|
border: 1px solid hsl(var(--sidebar-border)) !important;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop cursor line */
|
||||||
|
.react-arborist-tree [data-react-arborist-drop-cursor] {
|
||||||
|
background: hsl(var(--sidebar-primary)) !important;
|
||||||
|
height: 2px !important;
|
||||||
|
border-radius: 1px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make rows draggable */
|
||||||
|
.react-arborist-tree [draggable="true"] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-arborist-tree [draggable="true"]:active {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-arborist-tree [data-react-arborist-list-item] {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user