Compare commits
84
Commits
548f3743d1
...
master
Generated
+50
-1
@@ -14,7 +14,8 @@
|
||||
"next": "16.1.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"reactflow": "^11.11.4"
|
||||
"reactflow": "^11.11.4",
|
||||
"yjs": "^13.6.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -4975,6 +4976,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isomorphic.js": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/iterator.prototype": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
|
||||
@@ -5130,6 +5141,27 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lib0": {
|
||||
"version": "0.2.117",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
|
||||
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isomorphic.js": "^0.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||
"0gentesthtml": "bin/gentesthtml.js",
|
||||
"0serve": "bin/0serve.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
@@ -7186,6 +7218,23 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.29",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz",
|
||||
"integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.99"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"next": "16.1.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"reactflow": "^11.11.4"
|
||||
"reactflow": "^11.11.4",
|
||||
"yjs": "^13.6.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 6,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-056, US-065"
|
||||
},
|
||||
{
|
||||
@@ -127,7 +127,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 7,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-057, US-065"
|
||||
},
|
||||
{
|
||||
@@ -146,7 +146,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 8,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-057, US-065"
|
||||
},
|
||||
{
|
||||
@@ -164,7 +164,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 9,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-057, US-065"
|
||||
},
|
||||
{
|
||||
@@ -181,7 +181,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 10,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-054, US-058, US-059"
|
||||
},
|
||||
{
|
||||
@@ -199,7 +199,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 11,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-056, US-057"
|
||||
},
|
||||
{
|
||||
@@ -216,7 +216,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 12,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-058, US-059, US-060, US-061"
|
||||
},
|
||||
{
|
||||
@@ -232,7 +232,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 13,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
@@ -251,7 +251,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 14,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-043"
|
||||
},
|
||||
{
|
||||
@@ -269,7 +269,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 15,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-043"
|
||||
},
|
||||
{
|
||||
@@ -287,7 +287,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 16,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-045"
|
||||
},
|
||||
{
|
||||
@@ -306,7 +306,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 17,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-045"
|
||||
},
|
||||
{
|
||||
@@ -324,7 +324,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 18,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-045, US-046"
|
||||
},
|
||||
{
|
||||
@@ -341,7 +341,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 19,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-045, US-046"
|
||||
},
|
||||
{
|
||||
@@ -359,7 +359,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 20,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-045, US-048"
|
||||
},
|
||||
{
|
||||
@@ -376,7 +376,7 @@
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 21,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-043, US-048"
|
||||
},
|
||||
{
|
||||
@@ -394,7 +394,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 22,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-051"
|
||||
},
|
||||
{
|
||||
@@ -414,7 +414,7 @@
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 23,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": "Dependencies: US-052, US-048"
|
||||
}
|
||||
]
|
||||
|
||||
+408
@@ -28,11 +28,48 @@
|
||||
- Toast component supports an optional `action` prop: `{ label: string; onClick: () => void }` for retry/undo buttons
|
||||
- Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser
|
||||
- Character/Variable types (`Character`, `Variable`) and extracted node data types (`DialogueNodeData`, `VariableNodeData`) are in `src/types/flowchart.ts`
|
||||
- `EditorContext` at `src/components/editor/EditorContext.tsx` provides shared state (characters, onAddCharacter) to all custom node components via React context
|
||||
- Use `useEditorContext()` in node components to access project-level characters and variables without prop drilling through React Flow node data
|
||||
- New JSONB fields (characters, variables) must be defaulted to `[]` when reading from DB in page.tsx to handle pre-existing data
|
||||
- Reusable `Combobox` component at `src/components/editor/Combobox.tsx` - use for all character/variable dropdowns. Props: items (ComboboxItem[]), value, onChange, placeholder, onAddNew
|
||||
- `ProjectSettingsModal` at `src/components/editor/ProjectSettingsModal.tsx` manages characters/variables. Receives state + callbacks from FlowchartEditor
|
||||
- Characters and variables state is managed in `FlowchartEditorInner` with `useState` hooks, passed down to the modal
|
||||
- For settings-style modals, use `max-w-2xl h-[80vh]` with overflow-y-auto content area and fixed header/tabs
|
||||
- `EditorContext` provides both characters (onAddCharacter) and variables (onAddVariable) to node components. Use `useEditorContext()` to access them.
|
||||
- In FlowchartEditor, `handleAddVariable` adds a variable *node* to the canvas; `handleAddVariableDefinition` creates a variable *definition* in project data. Avoid naming collisions between "add node" and "add definition" callbacks.
|
||||
- Edge interactions use `onEdgeClick` on ReactFlow component. ConditionEditor opens as a modal overlay since React Flow edges don't support inline panels.
|
||||
- `Condition.value` supports `number | string | boolean` — always check variable type before rendering value inputs for edge conditions.
|
||||
- `OptionConditionEditor` at `src/components/editor/OptionConditionEditor.tsx` handles choice option conditions. Same pattern as `ConditionEditor` but with simpler props (no edgeId).
|
||||
- `ChoiceOption` type includes optional `condition?: Condition`. When counting variable usage, check variable nodes + edge conditions + choice option conditions.
|
||||
- React Compiler lint forbids `setState` in effects and reading `useRef().current` during render. Use `useState(() => computeValue())` lazy initializer pattern for one-time initialization logic.
|
||||
- For detecting legacy data shape (pre-migration), pass a flag from the server component (page.tsx) to the client component, since only the server reads raw DB data.
|
||||
- Collaboration tables: `project_collaborators` (roles), `collaboration_sessions` (presence), `audit_trail` (history) — all with RLS scoped by project ownership or collaborator membership
|
||||
- RLS pattern for shared resources: check `projects.user_id = auth.uid()` OR `project_collaborators.user_id = auth.uid()` to cover both owners and collaborators
|
||||
- `RealtimeConnection` class at `src/lib/collaboration/realtime.ts` manages Supabase Realtime channel lifecycle (connect, heartbeat, reconnect, disconnect). Instantiate with (projectId, userId, callbacks).
|
||||
- FlowchartEditor receives `userId` prop from page.tsx server component for collaboration features
|
||||
- Toolbar accepts optional `connectionState` prop to show green/yellow/red connection indicator
|
||||
- `collaboration_sessions` table has UNIQUE(project_id, user_id) constraint to support upsert-based session management
|
||||
- Server actions for project-specific operations go in `src/app/editor/[projectId]/actions.ts` — use `'use server'` directive and return `{ success: boolean; error?: string }` pattern
|
||||
- Editor page.tsx supports both owner and collaborator access: first checks ownership, then falls back to `project_collaborators` lookup. Pass `isOwner` prop to client component.
|
||||
- `ShareModal` at `src/components/editor/ShareModal.tsx` manages collaborator invites/roles/removal via server actions. Only owners see invite form.
|
||||
- Dashboard shared projects use Supabase join query: `project_collaborators.select('role, projects(id, name, updated_at)')` to fetch projects shared with the user
|
||||
- `ProjectCard` supports optional `shared` and `sharedRole` props — when `shared=true`, hide edit/delete buttons and show role badge instead
|
||||
- `PresenceAvatars` at `src/components/editor/PresenceAvatars.tsx` renders connected collaborator avatars. Receives `PresenceUser[]` from `RealtimeConnection.onPresenceSync`.
|
||||
- `RealtimeConnection` constructor takes `(projectId, userId, displayName, callbacks)` — `displayName` is broadcast via Supabase Realtime presence tracking
|
||||
- User color for presence is derived from a hash of their userId, ensuring consistency across sessions. Use `getUserColor(userId)` pattern from PresenceAvatars.
|
||||
- `CRDTManager` at `src/lib/collaboration/crdt.ts` wraps a Yjs Y.Doc with Y.Map<string> for nodes and edges. Connects to Supabase Realtime channel for broadcasting updates.
|
||||
- CRDT sync pattern: local React Flow changes → `updateNodes`/`updateEdges` on CRDTManager → Yjs broadcasts to channel; remote broadcasts → Yjs applies update → callbacks set React Flow state. Use `isRemoteUpdateRef` to prevent echo loops.
|
||||
- For Supabase Realtime broadcast of binary data (Yjs updates), convert `Uint8Array` → `Array.from()` for JSON payload, and `new Uint8Array()` on receive.
|
||||
- For ephemeral real-time data (cursors, typing indicators), use Supabase Realtime broadcast (`channel.send({ type: 'broadcast', event, payload })`) + `.on('broadcast', { event }, callback)` — not persistence-backed
|
||||
- `RemoteCursors` at `src/components/editor/RemoteCursors.tsx` renders collaborator cursors on canvas. Uses `useViewport()` to transform flow→screen coordinates. Throttle broadcasts to 50ms via timestamp ref.
|
||||
- Supabase Realtime presence events: `sync` (full state), `join` (arrivals with `newPresences` array), `leave` (departures with `leftPresences` array). Filter `this.userId` to skip own events.
|
||||
- `CollaborationToast` at `src/components/editor/CollaborationToast.tsx` shows join/leave notifications (bottom-left, auto-dismiss 3s). Uses `getUserColor(userId)` for accent color dot.
|
||||
- Node lock indicators use `EditorContext` (`nodeLocks` Map, `onNodeFocus`, `onNodeBlur`). Each node component checks `nodeLocks.get(id)` for lock state and renders `NodeLockIndicator` + overlay if locked by another user.
|
||||
- For ephemeral lock state (node editing locks), broadcast via `node-lock` event with `{ nodeId, userId, displayName, lockedAt }`. Send `nodeId: null` to release.
|
||||
- `AuditTrailRecorder` at `src/lib/collaboration/auditTrail.ts` records node/edge changes to `audit_trail` table. Uses state diffing (previous vs current Maps), 1-second per-entity debounce, and fire-and-forget Supabase inserts. Only records local changes (guarded by `isRemoteUpdateRef` in FlowchartEditor).
|
||||
- `ActivityHistorySidebar` at `src/components/editor/ActivityHistorySidebar.tsx` displays audit trail entries in a right sidebar. Rendered inside the canvas `relative flex-1` container. Toggle via `showHistory` state in FlowchartEditor. Exports `AuditEntry` type for consumers.
|
||||
- To prevent double audit recording when programmatically changing nodes/edges (e.g., revert), set a ref guard (`isRevertingRef`) before `setNodes`/`setEdges` and clear it with `setTimeout(() => ..., 0)`. Check the guard in the CRDT sync effects before calling `auditRef.current.recordNodeChanges()`.
|
||||
- For async data fetching in components with React Compiler, use a pure fetch function returning `{ data, error, hasMore }` result object, then handle setState in the `.then()` callback with an abort/mount guard — never call setState-containing functions directly inside useEffect.
|
||||
|
||||
---
|
||||
|
||||
@@ -97,3 +134,374 @@
|
||||
- The same form patterns from CharactersTab apply: inline form within the list for editing, appended form below the list for adding
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-059
|
||||
- What was implemented: Variable node variable dropdown using Combobox component, replacing the free-text input
|
||||
- Files changed:
|
||||
- `src/components/editor/nodes/VariableNode.tsx` - Replaced text input with Combobox for variable selection, added inline "Add new variable" form with name + type, added orange warning border for invalid references, filtered operation options (add/subtract only for numeric type)
|
||||
- `src/components/editor/EditorContext.tsx` - Extended context to include `variables: Variable[]` and `onAddVariable` callback
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `handleAddVariableDefinition` callback and passed variables + onAddVariable through EditorContext
|
||||
- **Learnings for future iterations:**
|
||||
- The existing `handleAddVariable` in FlowchartEditor adds a variable *node* to the canvas (toolbar action). The new `handleAddVariableDefinition` creates a variable *definition* in the project's data. Name carefully to avoid collisions.
|
||||
- EditorContext is the shared context for node components to access project-level characters and variables. Extend it when new entity types need to be accessible from custom node components.
|
||||
- The VariableNode follows the same pattern as DialogueNode for Combobox integration: items derived via useMemo, handleSelect sets both variableId and variableName, inline add form for quick creation, hasInvalidReference for warning state.
|
||||
- Operations filtering uses `isNumeric` flag: if no variable is selected (undefined) or type is 'numeric', all operations are shown; otherwise only 'set' is available. When selecting a non-numeric variable, operation is auto-reset to 'set' if it was 'add' or 'subtract'.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-060
|
||||
- What was implemented: Edge condition variable dropdown using Combobox component, replacing free-text input with a type-aware condition editor modal
|
||||
- Files changed:
|
||||
- `src/types/flowchart.ts` - Updated `Condition.value` type from `number` to `number | string | boolean` to support all variable types
|
||||
- `src/components/editor/ConditionEditor.tsx` - New component: modal-based condition editor with Combobox for variable selection, type-aware operator filtering, type-adaptive value inputs, inline "Add new variable" form, orange warning for invalid references, and "Remove condition" action
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `onEdgeClick` handler to open ConditionEditor, `handleConditionChange` to update edge condition data, `selectedEdgeId` state, and ConditionEditor rendering
|
||||
- **Learnings for future iterations:**
|
||||
- Edge interactions in React Flow use `onEdgeClick` prop on the ReactFlow component (not on individual edges). The handler receives `(event: React.MouseEvent, edge: Edge)`.
|
||||
- The ConditionEditor is rendered as a modal overlay (fixed z-50), not as part of the edge itself — since edges don't have built-in panel/popover support in React Flow.
|
||||
- `Condition.value` was originally typed as just `number` but needed broadening to `number | string | boolean` to support string/boolean variables in conditions. This change didn't break existing code since the VariableNode's `value` field is a separate type.
|
||||
- Operator filtering for non-numeric types: only `==` and `!=` are available for string/boolean variables. When switching from a numeric variable to a string/boolean, the operator auto-resets to `==` if it was a comparison operator.
|
||||
- Value input adapts to type: number input for numeric, text input for string, boolean dropdown for boolean.
|
||||
- The `selectedEdge` is derived via `useMemo` from `edges` state and `selectedEdgeId`, so it always reflects the latest condition data.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-061
|
||||
- What was implemented: Choice option condition variable dropdown using OptionConditionEditor component with Combobox
|
||||
- Files changed:
|
||||
- `src/types/flowchart.ts` - Added `condition?: Condition` to `ChoiceOption` type; moved `Condition` type definition before `ChoiceOption` for correct reference order
|
||||
- `src/components/editor/OptionConditionEditor.tsx` - New component: modal-based condition editor for choice options with Combobox variable selection, type-aware operators, type-adaptive value inputs, inline "Add new variable" form, orange warning for invalid references
|
||||
- `src/components/editor/nodes/ChoiceNode.tsx` - Added condition button per option (clipboard icon), condition summary text below options, OptionConditionEditor integration, EditorContext usage for variables, invalid reference detection with orange warning styling
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Extended `getVariableUsageCount` to also count variable references in choice option conditions
|
||||
- **Learnings for future iterations:**
|
||||
- The `OptionConditionEditor` follows the same pattern as `ConditionEditor` but with a simpler API: it doesn't need an edgeId since it works with a single option's condition via `onChange(condition | undefined)` callback
|
||||
- The `ChoiceOption` type in `flowchart.ts` now references `Condition`, which required reordering type definitions (Condition must be defined before ChoiceOption)
|
||||
- Each choice option shows a small clipboard icon button that turns blue when a condition is set, or orange when the referenced variable is invalid/deleted
|
||||
- A condition summary line (e.g., "if score > 10") appears below each option label when a condition is active
|
||||
- The `getVariableUsageCount` in FlowchartEditor now counts three sources: variable nodes, edge conditions, and choice option conditions
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-062
|
||||
- What was implemented: Auto-migration of existing free-text speaker/variable values to character/variable definitions on project load
|
||||
- Files changed:
|
||||
- `src/app/editor/[projectId]/page.tsx` - Added `needsMigration` flag that detects whether raw DB data has characters/variables arrays
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `computeMigration()` helper function and `needsMigration` prop; migration result initializes state directly via lazy `useState` to avoid React Compiler lint issues
|
||||
- `src/components/editor/nodes/DialogueNode.tsx` - Included pre-existing US-058 changes (speaker dropdown with Combobox) that were not previously committed
|
||||
- **Learnings for future iterations:**
|
||||
- React Compiler lint (`react-hooks/set-state-in-effect`) forbids calling `setState` synchronously within `useEffect`. For one-time initialization logic, compute the result and use it directly in state initializers instead.
|
||||
- React Compiler lint (`react-hooks/refs`) forbids reading `useRef().current` during render. Use `useState(() => ...)` lazy initializer pattern instead of `useRef` for values computed once at mount.
|
||||
- The migration detection relies on `rawData.characters` being `undefined` (old projects) vs `[]` (migrated projects). The `page.tsx` server component passes `needsMigration` flag to the client component since only the server has access to the raw DB shape.
|
||||
- `computeMigration` is a pure function called outside the component render cycle (via lazy useState). It uses `nanoid()` for IDs, so it must only be called once — lazy `useState` ensures this.
|
||||
- The toast message for migration is set as initial state, so it shows immediately on first render without needing an effect.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-063
|
||||
- What was implemented: Import characters/variables from another project via modal in project settings
|
||||
- Files changed:
|
||||
- `src/components/editor/ImportFromProjectModal.tsx` - New component: project list modal with checkbox selection for characters or variables, duplicate-by-name skipping with warnings, select all/none controls
|
||||
- `src/components/editor/ProjectSettingsModal.tsx` - Added `projectId` prop, `ImportFromProjectModal` integration, and "Import from project" buttons in both Characters and Variables tabs
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Passed `projectId` through to `ProjectSettingsModal`
|
||||
- **Learnings for future iterations:**
|
||||
- The `ImportFromProjectModal` uses `z-[60]` to layer above the `ProjectSettingsModal` (which uses `z-50`), since it's rendered as a child of that modal
|
||||
- Imported characters/variables get new IDs via `nanoid()` to avoid ID collisions between projects. The original colors, types, and initial values are preserved.
|
||||
- Duplicate detection is case-insensitive by name. Duplicates are skipped (not overwritten) with a warning message shown to the user.
|
||||
- The `LoadingSpinner` component mentioned in Codebase Patterns doesn't exist; used inline text loading indicators instead.
|
||||
- Supabase client-side fetching from `createClient()` (browser) automatically scopes by the logged-in user's RLS policies, so fetching other projects just uses `.neq('id', currentProjectId)` and RLS handles ownership filtering.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-030
|
||||
- What was implemented: Right-click context menu for canvas, nodes, and edges
|
||||
- Files changed:
|
||||
- src/components/editor/ContextMenu.tsx - new component with menu items for different contexts (canvas/node/edge)
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated context menu with handlers for all actions
|
||||
- **Learnings for future iterations:**
|
||||
- Use `onPaneContextMenu`, `onNodeContextMenu`, and `onEdgeContextMenu` React Flow callbacks for context menus
|
||||
- `screenToFlowPosition()` converts screen coordinates to flow coordinates for placing nodes at click position
|
||||
- Context menu state includes type ('canvas'|'node'|'edge') and optional nodeId/edgeId for targeted actions
|
||||
- Use `document.addEventListener('click', handler)` and `e.stopPropagation()` on menu to close on outside click
|
||||
- Escape key listener via `document.addEventListener('keydown', handler)` for menu close
|
||||
- NodeMouseHandler and EdgeMouseHandler types from reactflow provide proper typing for context menu callbacks
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-031
|
||||
- What was implemented: Condition editor modal for adding/editing/removing conditions on edges
|
||||
- Files changed:
|
||||
- src/components/editor/ConditionEditor.tsx - new modal component with form for variable name, operator, and value
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated condition editor with double-click and context menu triggers
|
||||
- **Learnings for future iterations:**
|
||||
- Use `onEdgeDoubleClick` React Flow callback for double-click on edges
|
||||
- Store condition editor state separately from context menu state (`conditionEditor` vs `contextMenu`)
|
||||
- Use `edge.data.condition` to access condition object on edges
|
||||
- When removing properties from edge data, use `delete` operator instead of destructuring to avoid lint warnings about unused variables
|
||||
- Condition type has operators: '>' | '<' | '==' | '>=' | '<=' | '!='
|
||||
- Preview condition in modal using template string: `${variableName} ${operator} ${value}`
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-032
|
||||
- What was implemented: Display conditions on edges with dashed styling and labels
|
||||
- Files changed:
|
||||
- src/components/editor/edges/ConditionalEdge.tsx - new custom edge component with condition display
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated custom edge type, added EdgeTypes import and edgeTypes definition
|
||||
- **Learnings for future iterations:**
|
||||
- Custom React Flow edges use EdgeProps<T> typing where T is the data shape
|
||||
- Use `BaseEdge` component for rendering the edge path, and `EdgeLabelRenderer` for positioning labels
|
||||
- `getSmoothStepPath` returns [edgePath, labelX, labelY] - labelX/labelY are center coordinates for labels
|
||||
- Custom edge types are registered in edgeTypes object (similar to nodeTypes) and passed to ReactFlow
|
||||
- Style edges with conditions using strokeDasharray: '5 5' for dashed lines
|
||||
- Custom edges go in `src/components/editor/edges/` directory
|
||||
- Use amber color scheme for conditional edges to distinguish from regular edges
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-033
|
||||
- What was implemented: Auto-save to LocalStorage with debounced saves and draft restoration prompt
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - added LocalStorage auto-save functionality, draft check on load, and restoration prompt UI
|
||||
- **Learnings for future iterations:**
|
||||
- Use lazy useState initializer for draft check to avoid ESLint "setState in effect" warning
|
||||
- LocalStorage key format: `vnwrite-draft-{projectId}` for project-specific drafts
|
||||
- Debounce saves with 1 second delay using useRef for timer tracking
|
||||
- Convert React Flow Node/Edge types back to app types using helper functions (fromReactFlowNodes, fromReactFlowEdges)
|
||||
- React Flow Edge has `sourceHandle: string | null | undefined` but app types use `string | undefined` - use nullish coalescing (`?? undefined`)
|
||||
- Check `typeof window === 'undefined'` in lazy initializer for SSR safety
|
||||
- clearDraft is exported for use in save functionality (US-034) to clear draft after successful database save
|
||||
- JSON.stringify comparison works for flowchart data equality check
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-034
|
||||
- What was implemented: Save project to database functionality
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleSave with Supabase update, added isSaving state and Toast notifications
|
||||
- src/components/editor/Toolbar.tsx - added isSaving prop with loading spinner indicator
|
||||
- **Learnings for future iterations:**
|
||||
- Use createClient() from lib/supabase/client.ts for browser-side database operations
|
||||
- Supabase update returns { error } object for error handling
|
||||
- Use async/await with try/catch for async save operations
|
||||
- Set updated_at manually with new Date().toISOString() for Supabase JSONB updates
|
||||
- Clear LocalStorage draft after successful save to avoid stale drafts
|
||||
- Toast state uses object with message and type for flexibility
|
||||
- Loading spinner SVG with animate-spin class for visual feedback during save
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-035
|
||||
- What was implemented: Export project as .vnflow file functionality
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleExport with blob creation and download trigger, added projectName prop
|
||||
- src/app/editor/[projectId]/page.tsx - passed projectName prop to FlowchartEditor
|
||||
- **Learnings for future iterations:**
|
||||
- Use Blob with type 'application/json' for JSON file downloads
|
||||
- JSON.stringify(data, null, 2) creates pretty-printed JSON with 2-space indentation
|
||||
- URL.createObjectURL creates a temporary URL for the blob
|
||||
- Create temporary anchor element with download attribute to trigger file download
|
||||
- Remember to cleanup: remove the anchor from DOM and revoke the object URL
|
||||
- Props needed for export: pass data down from server components (e.g., projectName) to client components that need them
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-036
|
||||
- What was implemented: Import project from .vnflow file functionality
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleImport, handleFileSelect, validation, and confirmation dialog for unsaved changes
|
||||
- **Learnings for future iterations:**
|
||||
- Use hidden `<input type="file">` element with ref to trigger file picker programmatically via `ref.current?.click()`
|
||||
- Accept multiple file extensions using comma-separated values in `accept` attribute: `accept=".vnflow,.json"`
|
||||
- Reset file input value after selection (`event.target.value = ''`) to allow re-selecting the same file
|
||||
- Use FileReader API with `readAsText()` for reading file contents, handle onload and onerror callbacks
|
||||
- Type guard function `isValidFlowchartData()` validates imported JSON structure before loading
|
||||
- Track unsaved changes by comparing current state to initialData using JSON.stringify comparison
|
||||
- Show confirmation dialog before import if there are unsaved changes to prevent accidental data loss
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-037
|
||||
- What was implemented: Export to Ren'Py JSON format functionality
|
||||
- Files changed:
|
||||
- src/components/editor/Toolbar.tsx - added 'Export to Ren'Py' button with purple styling
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented Ren'Py export types, conversion functions, and handleExportRenpy callback
|
||||
- **Learnings for future iterations:**
|
||||
- Ren'Py export uses typed interfaces for different node types: RenpyDialogueNode, RenpyMenuNode, RenpyVariableNode
|
||||
- Find first node by identifying nodes with no incoming edges (not in any edge's target set)
|
||||
- Use graph traversal (DFS) to organize nodes into labeled sections based on flow
|
||||
- Choice nodes create branching sections - save current section before processing each branch
|
||||
- Track visited nodes to detect cycles and create proper labels for jump references
|
||||
- Labels are generated based on speaker name or incremental counter for uniqueness
|
||||
- Replace node IDs with proper labels in a second pass after traversal completes
|
||||
- Include metadata (projectName, exportedAt) at the top level of the export
|
||||
- Validate JSON output with JSON.parse before download to ensure validity
|
||||
- Use purple color scheme for Ren'Py-specific button to distinguish from generic export
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-038
|
||||
- What was implemented: Unsaved changes warning with dirty state tracking, beforeunload, and navigation confirmation modal
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - added isDirty tracking via useMemo comparing current state to lastSavedDataRef, beforeunload event handler, navigation warning modal, back button with handleBackClick, moved header from page.tsx into this component
|
||||
- src/app/editor/[projectId]/page.tsx - simplified to only render FlowchartEditor (header moved to client component for dirty state access)
|
||||
- **Learnings for future iterations:**
|
||||
- Dirty state tracking uses useMemo comparing JSON.stringify of current flowchart data to a lastSavedDataRef
|
||||
- lastSavedDataRef is a useRef initialized with initialData and updated after successful save
|
||||
- Browser beforeunload requires both event.preventDefault() and setting event.returnValue = '' for modern browsers
|
||||
- Header with back navigation was moved from server component (page.tsx) to client component (FlowchartEditor.tsx) so it can access isDirty state
|
||||
- Back button uses handleBackClick which checks isDirty before navigating or showing confirmation modal
|
||||
- Navigation warning modal shows "Leave Page" (red) and "Stay" buttons for clear user action
|
||||
- "(unsaved changes)" indicator shown next to project name when isDirty is true
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-039
|
||||
- What was implemented: Loading and error states with reusable spinner, error page, and toast retry
|
||||
- Files changed:
|
||||
- src/components/LoadingSpinner.tsx - new reusable loading spinner component with size variants and optional message
|
||||
- src/app/editor/[projectId]/loading.tsx - updated to use LoadingSpinner component
|
||||
- src/app/editor/[projectId]/page.tsx - replaced notFound() with custom error UI showing "Project Not Found" with back to dashboard link
|
||||
- src/components/Toast.tsx - added optional action prop for action buttons (e.g., retry)
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated toast state type to include action, save error now shows retry button via handleSaveRef pattern
|
||||
- **Learnings for future iterations:**
|
||||
- Use a ref (handleSaveRef) to break circular dependency when a useCallback needs to reference itself for retry logic
|
||||
- Toast action prop uses `{ label: string; onClick: () => void }` for flexible action buttons
|
||||
- Don't auto-dismiss toasts that have action buttons (users need time to click them)
|
||||
- Replace `notFound()` with inline error UI when you need custom styling and navigation links
|
||||
- LoadingSpinner uses size prop ('sm' | 'md' | 'lg') for flexibility across different contexts
|
||||
- Link component from next/link is needed in server components for navigation (no useRouter in server components)
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-040
|
||||
- What was implemented: Conditionals on choice options - per-option visibility conditions
|
||||
- Files changed:
|
||||
- src/types/flowchart.ts - moved Condition type before ChoiceOption, added optional condition field to ChoiceOption
|
||||
- src/components/editor/nodes/ChoiceNode.tsx - added condition button per option, condition badge display, condition editing state management
|
||||
- src/components/editor/OptionConditionEditor.tsx - new modal component for editing per-option conditions (variable name, operator, value)
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated Ren'Py export to include per-option conditions (option condition takes priority over edge condition)
|
||||
- **Learnings for future iterations:**
|
||||
- Per-option conditions use the same Condition type as edge conditions
|
||||
- Condition type needed to be moved above ChoiceOption in types file since ChoiceOption now references it
|
||||
- Use `delete obj.property` pattern instead of destructuring with unused variable to avoid lint warnings
|
||||
- OptionConditionEditor is separate from ConditionEditor because it operates on option IDs vs edge IDs
|
||||
- In Ren'Py export, option-level condition takes priority over edge condition since it represents visibility
|
||||
- Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not
|
||||
- Condition badge below option shows "if variableName operator value" text in compact format
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-041
|
||||
- What was implemented: Change password for logged-in user from settings page
|
||||
- Files changed:
|
||||
- src/app/dashboard/settings/page.tsx - new client component with password change form (current, new, confirm fields)
|
||||
- src/components/Navbar.tsx - added "Settings" link to navbar
|
||||
- **Learnings for future iterations:**
|
||||
- Settings page lives under /dashboard/settings to reuse the dashboard layout (navbar, auth check)
|
||||
- Re-authentication uses signInWithPassword with current password before allowing updateUser
|
||||
- Supabase getUser() returns current user email needed for re-auth signInWithPassword call
|
||||
- Password validation: check match and minimum length (6 chars) before making API calls
|
||||
- Clear form fields after successful password update for security
|
||||
- Settings link in navbar uses neutral zinc colors to distinguish from admin/action links
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-042
|
||||
- What was implemented: Password reset modal that automatically appears when a recovery token is detected in the URL
|
||||
- Files changed:
|
||||
- src/components/PasswordResetModal.tsx - new client component with modal that detects recovery tokens from URL hash, sets session, and provides password reset form
|
||||
- src/app/login/page.tsx - integrated PasswordResetModal component on the login page
|
||||
- **Learnings for future iterations:**
|
||||
- PasswordResetModal is a standalone component that can be placed on any page to detect recovery tokens
|
||||
- Use window.history.replaceState to clean the URL hash after extracting the token (prevents re-triggering on refresh)
|
||||
- Separate tokenError state from form error state to show different UI (expired link vs. form validation)
|
||||
- Modal uses fixed positioning with z-50 to overlay above page content
|
||||
- After successful password update, sign out the user and redirect to login with success message (same as reset-password page)
|
||||
- The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-047
|
||||
- What was implemented: Live cursor positions on canvas showing collaborators' mouse positions in real-time
|
||||
- Files changed:
|
||||
- `src/lib/collaboration/realtime.ts` - Added `CursorPosition`, `RemoteCursor` types, `onCursorUpdate` callback to `RealtimeCallbacks`, broadcast listener for 'cursor' events, and `broadcastCursor()` method
|
||||
- `src/components/editor/RemoteCursors.tsx` - New component: renders colored arrow cursors with user name labels, smooth position interpolation via CSS transition, 5-second fade-out for inactive cursors, flow-to-screen coordinate transformation using React Flow viewport
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `remoteCursors` state, `cursorThrottleRef` for 50ms throttling, `handleMouseMove` that converts screen→flow coordinates and broadcasts via RealtimeConnection, cleanup of cursors for disconnected users, rendering of RemoteCursors overlay
|
||||
- `src/app/editor/[projectId]/page.tsx` - Fixed broken JSX structure (malformed HTML nesting and dead code after return)
|
||||
- **Learnings for future iterations:**
|
||||
- `screenToFlowPosition` from `useReactFlow()` converts screen-relative mouse coordinates to flow coordinates; for the reverse (rendering cursors), multiply by viewport.zoom and add viewport offset
|
||||
- Cursor broadcast uses Supabase Realtime broadcast (not presence) for efficiency: `channel.send({ type: 'broadcast', event: 'cursor', payload })`. Broadcast is fire-and-forget (no persistence).
|
||||
- React Compiler lint treats `Date.now()` as an impure function call — use `useState(() => Date.now())` lazy initializer pattern instead of `useState(Date.now())`
|
||||
- Throttling mouse events uses a ref storing the last broadcast timestamp (`cursorThrottleRef`), checked at the start of the handler before computing flow position
|
||||
- Remote cursors are removed when their user disconnects (filtered by `presenceUsers` list changes)
|
||||
- CSS `transition: transform 80ms linear` provides smooth interpolation between position updates without needing requestAnimationFrame
|
||||
- The `page.tsx` had a corrupted structure with unclosed tags and dead code — likely from a failed merge. Fixed by restructuring the error/not-found case into a proper early return
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-23 - US-050
|
||||
- What was implemented: Join/leave toast notifications when collaborators connect or disconnect from the editing session
|
||||
- Files changed:
|
||||
- `src/lib/collaboration/realtime.ts` - Added `onPresenceJoin` and `onPresenceLeave` callbacks to `RealtimeCallbacks` type; added Supabase Realtime `presence.join` and `presence.leave` event listeners that filter out own user and invoke callbacks
|
||||
- `src/components/editor/CollaborationToast.tsx` - New component: renders a compact toast notification with user's presence color dot, "[Name] joined" or "[Name] left" message, auto-dismisses after 3 seconds
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `getUserColor` helper (same hash logic as PresenceAvatars), `collaborationNotifications` state, `onPresenceJoin`/`onPresenceLeave` handlers on RealtimeConnection, `handleDismissNotification` callback, and rendering of CollaborationToast list in bottom-left corner
|
||||
- **Learnings for future iterations:**
|
||||
- Supabase Realtime presence has three event types: `sync` (full state), `join` (new arrivals), and `leave` (departures). Each provides an array of presences (`newPresences`/`leftPresences`). Use all three for different purposes.
|
||||
- The `join` event fires for each newly tracked presence. It includes the presence payload (userId, displayName) that was passed to `channel.track()`.
|
||||
- Collaboration notifications are positioned `bottom-left` (`left-4`) to avoid overlapping with the existing Toast component which is `bottom-right` (`right-4`).
|
||||
- The `getUserColor` function is duplicated from PresenceAvatars to avoid circular imports. Both use the same hash-to-color-index algorithm with the same RANDOM_COLORS palette for consistency.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-24 - US-049
|
||||
- What was implemented: Node editing lock indicators that show when another collaborator is editing a node
|
||||
- Files changed:
|
||||
- `src/lib/collaboration/realtime.ts` - Added `NodeLock` type, `onNodeLockUpdate` callback to `RealtimeCallbacks`, `node-lock` broadcast listener, and `broadcastNodeLock()` method
|
||||
- `src/components/editor/NodeLockIndicator.tsx` - New component: renders a colored border and name label overlay on locked nodes
|
||||
- `src/components/editor/EditorContext.tsx` - Extended context with `NodeLockInfo` type, `nodeLocks` (Map), `onNodeFocus`, and `onNodeBlur` callbacks
|
||||
- `src/components/editor/nodes/DialogueNode.tsx` - Added lock detection, `NodeLockIndicator` rendering, "Being edited by [name]" overlay, `onFocus`/`onBlur` handlers
|
||||
- `src/components/editor/nodes/VariableNode.tsx` - Same lock indicator pattern as DialogueNode
|
||||
- `src/components/editor/nodes/ChoiceNode.tsx` - Same lock indicator pattern as DialogueNode
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `nodeLocks` state (Map<string, NodeLockInfo>), `localLockRef` for tracking own lock, `handleNodeFocus`/`handleNodeBlur` callbacks, `onNodeLockUpdate` handler in RealtimeConnection, lock expiry timer (60s check every 5s), lock cleanup on user leave and component unmount, extended `editorContextValue` with lock state
|
||||
- **Learnings for future iterations:**
|
||||
- Node lock uses Supabase Realtime broadcast (like cursors) — ephemeral, not persisted to DB. Event name: `node-lock`.
|
||||
- Lock broadcasting uses `nodeId: string | null` pattern: non-null to acquire lock, `null` to release. The receiving side maps userId to their current lock.
|
||||
- Lock expiry uses a 60-second timeout checked every 5 seconds via `setInterval`. The `lockedAt` timestamp is broadcast with the lock payload.
|
||||
- Each node component accesses lock state via `EditorContext` (`nodeLocks` Map). The `NodeLockInfo` type extends `NodeLock` with a `color` field derived from `getUserColor()`.
|
||||
- `onFocus`/`onBlur` on the node container div fires when any child input gains/loses focus (focus event bubbles), which naturally maps to "user is editing this node".
|
||||
- Lock release on disconnect: broadcast `null` lock before calling `connection.disconnect()` in the cleanup return of the mount effect.
|
||||
- Locks from disconnected users are cleaned up in the same effect that removes cursors (filtered by `presenceUsers` list).
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-24 - US-051
|
||||
- What was implemented: Audit trail recording that writes all node/edge add/update/delete operations to the `audit_trail` table with debouncing and fire-and-forget semantics
|
||||
- Files changed:
|
||||
- `src/lib/collaboration/auditTrail.ts` - New `AuditTrailRecorder` class: tracks previous node/edge state, diffs against current state to detect add/update/delete operations, debounces writes per entity (1 second), merges rapid sequential actions (e.g., add+update=add, add+delete=no-op), fire-and-forget Supabase inserts with error logging, flush on destroy
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `auditRef` (AuditTrailRecorder), initialized in mount effect alongside CRDT manager, records node/edge changes in the CRDT sync effects (only for local changes, skipped for remote updates), destroyed on unmount
|
||||
- **Learnings for future iterations:**
|
||||
- The audit recorder piggybacks on the same CRDT sync effects that already compute `nodesForCRDT`/`edgesForCRDT` — this avoids duplicating the React Flow → FlowchartNode/Edge conversion.
|
||||
- The `isRemoteUpdateRef` guard in the sync effects ensures audit entries are only created for local user actions, not for changes received from other collaborators (those users' own recorders will handle their audit entries).
|
||||
- Debouncing per entity (1 second) prevents rapid edits (e.g., typing in a text field) from flooding the audit table. The merge logic handles transient states (add+delete within 1s = skip).
|
||||
- The `destroy()` method flushes pending entries synchronously on unmount, ensuring in-flight edits aren't lost when navigating away.
|
||||
- Supabase `.insert().then()` pattern provides fire-and-forget writes with error logging — the async operation doesn't block the editing flow.
|
||||
- No browser testing needed — this is a developer/infrastructure story with no UI changes.
|
||||
---
|
||||
|
||||
## 2026-01-24 - US-052
|
||||
- What was implemented: Activity history sidebar that displays audit trail entries, grouped by time period, with entity selection on click
|
||||
- Files changed:
|
||||
- `src/components/editor/ActivityHistorySidebar.tsx` - New component: right sidebar panel showing chronological audit trail entries, grouped by Today/Yesterday/Earlier, with user color accents, entity descriptions, paginated loading (20 per page), and click-to-select entity on canvas
|
||||
- `src/components/editor/Toolbar.tsx` - Added `onHistory` prop and "History" button in the right toolbar section
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `showHistory` state, `handleHistorySelectEntity` callback (selects nodes/edges on canvas), `ActivityHistorySidebar` import and rendering inside the canvas area, `onHistory` toggle prop on Toolbar
|
||||
- **Learnings for future iterations:**
|
||||
- React Compiler lint (`react-hooks/set-state-in-effect`) treats any function that calls setState as problematic when invoked inside useEffect — even if the setState is in an async `.then()` callback. To avoid this, extract data fetching into a pure function that returns a result object, then handle setState only in the `.then()` callback after checking a mounted/aborted guard.
|
||||
- For right sidebar panels overlaying the canvas, use `absolute right-0 top-0 z-40 h-full w-80` inside the `relative flex-1` canvas container. This keeps the sidebar within the canvas area without affecting the toolbar.
|
||||
- The `audit_trail` table has an index on `(project_id, created_at DESC)` which makes paginated queries efficient. Use `.range(offset, offset + PAGE_SIZE - 1)` for Supabase pagination.
|
||||
- Entity descriptions are derived from `new_state` (for adds/updates) or `previous_state` (for deletes). The state contains the full node/edge data including `type`, `data.speaker`, `data.question`, `data.variableName`.
|
||||
- Deleted entities (`action_type.endsWith('_delete')`) cannot be selected on canvas since they no longer exist — render those entries as disabled (no click handler, reduced opacity).
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
## 2026-01-24 - US-053
|
||||
- What was implemented: Revert changes from audit trail — each history entry has a Revert button, confirmation dialog with before/after preview, and revert logic that reverses node/edge add/update/delete operations
|
||||
- Files changed:
|
||||
- `src/components/editor/RevertConfirmDialog.tsx` - New component: confirmation dialog showing action description, before/after state previews, with Cancel and Revert buttons
|
||||
- `src/components/editor/ActivityHistorySidebar.tsx` - Added Revert button (shows on hover per entry), `revertEntry` state, `RevertConfirmDialog` rendering, exported `AuditEntry` type, added `onRevert` prop
|
||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `handleRevertEntry` callback implementing all revert cases (node add→delete, node update→restore, node delete→recreate, same for edges), `isRevertingRef` to prevent double audit recording, `getRevertActionType` helper, explicit audit trail write for the revert itself, toast notification on success
|
||||
- **Learnings for future iterations:**
|
||||
- Reverting uses the same `setNodes`/`setEdges` state setters, so CRDT sync happens automatically through the existing effects that watch `nodesForCRDT`/`edgesForCRDT` — no explicit CRDT call needed.
|
||||
- To prevent double audit recording (once from the sync effect, once from the explicit revert write), use an `isRevertingRef` guard set synchronously before `setNodes`/`setEdges` and cleared with `setTimeout(() => ..., 0)` after React processes the state updates.
|
||||
- The revert audit entry uses inverse action types: reverting `node_add` records `node_delete`, reverting `node_delete` records `node_add`, `node_update` stays `node_update`.
|
||||
- The `previous_state` and `new_state` are swapped for the revert audit entry: what was `new_state` (the current state being reverted) becomes `previous_state`, and what was `previous_state` (the state being restored) becomes `new_state`.
|
||||
- Reverting a `node_add` also removes connected edges to prevent dangling edge references.
|
||||
- The RevertConfirmDialog uses `z-[70]` to layer above the ActivityHistorySidebar (`z-40`).
|
||||
- The Revert button uses CSS `group-hover:inline-block` pattern to appear only on entry hover, keeping the UI clean.
|
||||
- No browser testing tools are available; manual verification is needed.
|
||||
---
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import NewProjectButton from '@/components/NewProjectButton'
|
||||
import ProjectList from '@/components/ProjectList'
|
||||
import ProjectCard from '@/components/ProjectCard'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient()
|
||||
@@ -19,6 +20,21 @@ export default async function DashboardPage() {
|
||||
.eq('user_id', user.id)
|
||||
.order('updated_at', { ascending: false })
|
||||
|
||||
// Fetch shared projects (projects where this user is a collaborator)
|
||||
const { data: collaborations } = await supabase
|
||||
.from('project_collaborators')
|
||||
.select('role, projects(id, name, updated_at)')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
const sharedProjects = (collaborations || [])
|
||||
.filter((c) => c.projects)
|
||||
.map((c) => ({
|
||||
...(c.projects as unknown as { id: string; name: string; updated_at: string }),
|
||||
shared: true,
|
||||
role: c.role,
|
||||
}))
|
||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
@@ -44,6 +60,26 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<ProjectList initialProjects={projects || []} />
|
||||
|
||||
{sharedProjects.length > 0 && (
|
||||
<div className="mt-10">
|
||||
<h2 className="mb-4 text-xl font-bold text-zinc-900 dark:text-zinc-50">
|
||||
Shared with me
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sharedProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
id={project.id}
|
||||
name={project.name}
|
||||
updatedAt={project.updated_at}
|
||||
shared
|
||||
sharedRole={project.role}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const supabase = createClient()
|
||||
|
||||
// Re-authenticate with current password
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user?.email) {
|
||||
setError('Unable to verify current user.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: user.email,
|
||||
password: currentPassword,
|
||||
})
|
||||
|
||||
if (signInError) {
|
||||
setError('Current password is incorrect.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Update to new password
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password: newPassword,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess('Password updated successfully.')
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch {
|
||||
setError('An unexpected error occurred.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 mb-8">
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<div className="max-w-md">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50 mb-4">
|
||||
Change Password
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="currentPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||
>
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="newPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-zinc-900"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export type Collaborator = {
|
||||
id: string
|
||||
user_id: string
|
||||
role: 'owner' | 'editor' | 'viewer'
|
||||
invited_at: string
|
||||
accepted_at: string | null
|
||||
display_name: string | null
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export async function getCollaborators(
|
||||
projectId: string
|
||||
): Promise<{ success: boolean; data?: Collaborator[]; error?: string }> {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
// Verify user owns the project
|
||||
const { data: project } = await supabase
|
||||
.from('projects')
|
||||
.select('id, user_id')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
if (!project) {
|
||||
return { success: false, error: 'Project not found' }
|
||||
}
|
||||
|
||||
const isOwner = project.user_id === user.id
|
||||
|
||||
// Check if user is a collaborator
|
||||
if (!isOwner) {
|
||||
const { data: collab } = await supabase
|
||||
.from('project_collaborators')
|
||||
.select('id')
|
||||
.eq('project_id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!collab) {
|
||||
return { success: false, error: 'Access denied' }
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch collaborators with profile info
|
||||
const { data: collaborators, error } = await supabase
|
||||
.from('project_collaborators')
|
||||
.select('id, user_id, role, invited_at, accepted_at, profiles(display_name, email)')
|
||||
.eq('project_id', projectId)
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
const result: Collaborator[] = (collaborators || []).map((c) => {
|
||||
const profile = c.profiles as unknown as { display_name: string | null; email: string | null } | null
|
||||
return {
|
||||
id: c.id,
|
||||
user_id: c.user_id,
|
||||
role: c.role as 'owner' | 'editor' | 'viewer',
|
||||
invited_at: c.invited_at,
|
||||
accepted_at: c.accepted_at,
|
||||
display_name: profile?.display_name || null,
|
||||
email: profile?.email || null,
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, data: result }
|
||||
}
|
||||
|
||||
export async function inviteCollaborator(
|
||||
projectId: string,
|
||||
email: string,
|
||||
role: 'editor' | 'viewer'
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
// Verify user owns the project
|
||||
const { data: project } = await supabase
|
||||
.from('projects')
|
||||
.select('id, user_id')
|
||||
.eq('id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!project) {
|
||||
return { success: false, error: 'Only the project owner can invite collaborators' }
|
||||
}
|
||||
|
||||
// Find the user by email in profiles
|
||||
const { data: targetProfile } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, email')
|
||||
.eq('email', email)
|
||||
.single()
|
||||
|
||||
if (!targetProfile) {
|
||||
return { success: false, error: 'No user found with that email address' }
|
||||
}
|
||||
|
||||
// Cannot invite yourself
|
||||
if (targetProfile.id === user.id) {
|
||||
return { success: false, error: 'You cannot invite yourself' }
|
||||
}
|
||||
|
||||
// Check if already a collaborator
|
||||
const { data: existing } = await supabase
|
||||
.from('project_collaborators')
|
||||
.select('id')
|
||||
.eq('project_id', projectId)
|
||||
.eq('user_id', targetProfile.id)
|
||||
.single()
|
||||
|
||||
if (existing) {
|
||||
return { success: false, error: 'This user is already a collaborator' }
|
||||
}
|
||||
|
||||
// Insert collaborator
|
||||
const { error: insertError } = await supabase
|
||||
.from('project_collaborators')
|
||||
.insert({
|
||||
project_id: projectId,
|
||||
user_id: targetProfile.id,
|
||||
role,
|
||||
invited_at: new Date().toISOString(),
|
||||
accepted_at: new Date().toISOString(), // Auto-accept for now
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
return { success: false, error: insertError.message }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function updateCollaboratorRole(
|
||||
projectId: string,
|
||||
collaboratorId: string,
|
||||
role: 'editor' | 'viewer'
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
// Verify user owns the project
|
||||
const { data: project } = await supabase
|
||||
.from('projects')
|
||||
.select('id, user_id')
|
||||
.eq('id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!project) {
|
||||
return { success: false, error: 'Only the project owner can change roles' }
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('project_collaborators')
|
||||
.update({ role })
|
||||
.eq('id', collaboratorId)
|
||||
.eq('project_id', projectId)
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function removeCollaborator(
|
||||
projectId: string,
|
||||
collaboratorId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
// Verify user owns the project
|
||||
const { data: project } = await supabase
|
||||
.from('projects')
|
||||
.select('id, user_id')
|
||||
.eq('id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!project) {
|
||||
return { success: false, error: 'Only the project owner can remove collaborators' }
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('project_collaborators')
|
||||
.delete()
|
||||
.eq('id', collaboratorId)
|
||||
.eq('project_id', projectId)
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
|
||||
export default function EditorLoading() {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
@@ -9,31 +11,7 @@ export default function EditorLoading() {
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin text-blue-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Loading editor...
|
||||
</p>
|
||||
</div>
|
||||
<LoadingSpinner size="lg" message="Loading editor..." />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import FlowchartEditor from './FlowchartEditor'
|
||||
import type { FlowchartData } from '@/types/flowchart'
|
||||
@@ -20,25 +19,48 @@ export default async function EditorPage({ params }: PageProps) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { data: project, error } = await supabase
|
||||
// Fetch user's display name for presence
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('display_name')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const userDisplayName = profile?.display_name || user.email || 'Anonymous'
|
||||
|
||||
// Try to load as owner first
|
||||
const { data: ownedProject } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, flowchart_data')
|
||||
.eq('id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error || !project) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const rawData = project.flowchart_data || {}
|
||||
const flowchartData: FlowchartData = {
|
||||
nodes: rawData.nodes || [],
|
||||
edges: rawData.edges || [],
|
||||
characters: rawData.characters || [],
|
||||
variables: rawData.variables || [],
|
||||
let project = ownedProject
|
||||
let isOwner = true
|
||||
|
||||
// If not the owner, check if the user is a collaborator
|
||||
if (!project) {
|
||||
const { data: collab } = await supabase
|
||||
.from('project_collaborators')
|
||||
.select('id, role')
|
||||
.eq('project_id', projectId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (collab) {
|
||||
const { data: sharedProject } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, flowchart_data')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
project = sharedProject
|
||||
isOwner = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
@@ -46,6 +68,7 @@ export default async function EditorPage({ params }: PageProps) {
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||
aria-label="Back to dashboard"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -60,18 +83,62 @@ export default async function EditorPage({ params }: PageProps) {
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{project.name}
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1">
|
||||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
initialData={flowchartData}
|
||||
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<svg
|
||||
className="h-12 w-12 text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Project Not Found
|
||||
</h2>
|
||||
<p className="max-w-sm text-sm text-zinc-600 dark:text-zinc-400">
|
||||
The project you're looking for doesn't exist or you don't have access to it.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mt-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rawData = project.flowchart_data || {}
|
||||
const flowchartData: FlowchartData = {
|
||||
nodes: rawData.nodes || [],
|
||||
edges: rawData.edges || [],
|
||||
characters: rawData.characters || [],
|
||||
variables: rawData.variables || [],
|
||||
}
|
||||
|
||||
// Migration flag: if the raw data doesn't have characters/variables arrays,
|
||||
// the project was created before these features existed and may need auto-migration
|
||||
const needsMigration = !rawData.characters && !rawData.variables
|
||||
|
||||
return (
|
||||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
userId={user.id}
|
||||
userDisplayName={userDisplayName}
|
||||
isOwner={isOwner}
|
||||
initialData={flowchartData}
|
||||
needsMigration={needsMigration}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,3 +24,16 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Export validation warning highlighting for React Flow nodes */
|
||||
.react-flow__node.export-warning-node {
|
||||
outline: 3px solid #f97316;
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
animation: pulse-warning 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-warning {
|
||||
0%, 100% { outline-color: #f97316; }
|
||||
50% { outline-color: #fb923c; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Check for success message from password reset
|
||||
const message = searchParams.get('message')
|
||||
const successMessage = message === 'password_reset_success'
|
||||
? 'Your password has been reset successfully. Please sign in with your new password.'
|
||||
: null
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
WebVNWrite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{successMessage && (
|
||||
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">{successMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+11
-116
@@ -1,130 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Suspense } from 'react'
|
||||
import LoginForm from './LoginForm'
|
||||
import PasswordResetModal from '@/components/PasswordResetModal'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Check for success message from password reset
|
||||
const message = searchParams.get('message')
|
||||
const successMessage = message === 'password_reset_success'
|
||||
? 'Your password has been reset successfully. Please sign in with your new password.'
|
||||
: null
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="w-full max-w-md space-y-8 text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
WebVNWrite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to your account
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{successMessage && (
|
||||
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">{successMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
}
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
<PasswordResetModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-62
@@ -1,65 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function SignupForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
// Pre-fill email if provided in URL (from invite link)
|
||||
const [email, setEmail] = useState(searchParams.get('email') ?? '')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Handle invite/signup token from URL hash
|
||||
// Supabase adds tokens to the URL hash after redirect
|
||||
const handleTokenFromUrl = async () => {
|
||||
const hash = window.location.hash
|
||||
if (hash) {
|
||||
const params = new URLSearchParams(hash.substring(1))
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const type = params.get('type')
|
||||
|
||||
if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError('Invalid or expired invite link. Please request a new invitation.')
|
||||
return
|
||||
}
|
||||
|
||||
// Get the user's email from the session
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user?.email) {
|
||||
setEmail(user.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTokenFromUrl()
|
||||
}, [searchParams])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
// Check if user already has a session (from invite link)
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
if (session) {
|
||||
// User was invited and has a session - update their password
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Create profile record
|
||||
const { error: profileError } = await supabase.from('profiles').upsert({
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
display_name: session.user.email?.split('@')[0] || 'User',
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
// Regular signup flow (if allowed)
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
// Create profile record
|
||||
const { error: profileError } = await supabase.from('profiles').upsert({
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
display_name: email.split('@')[0] || 'User',
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
WebVNWrite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Complete your account setup
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+9
-224
@@ -1,238 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Suspense } from 'react'
|
||||
import SignupForm from './SignupForm'
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
// Pre-fill email if provided in URL (from invite link)
|
||||
const [email, setEmail] = useState(searchParams.get('email') ?? '')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Handle invite/signup token from URL hash
|
||||
// Supabase adds tokens to the URL hash after redirect
|
||||
const handleTokenFromUrl = async () => {
|
||||
const hash = window.location.hash
|
||||
if (hash) {
|
||||
const params = new URLSearchParams(hash.substring(1))
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const type = params.get('type')
|
||||
|
||||
if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError('Invalid or expired invite link. Please request a new invitation.')
|
||||
return
|
||||
}
|
||||
|
||||
// Get the user's email from the session
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user?.email) {
|
||||
setEmail(user.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTokenFromUrl()
|
||||
}, [searchParams])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
// Check if user already has a session (from invite link)
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
if (session) {
|
||||
// User was invited and has a session - update their password
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Create profile record
|
||||
const { error: profileError } = await supabase.from('profiles').upsert({
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
display_name: session.user.email?.split('@')[0] || 'User',
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
// Regular signup flow (if allowed)
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
// Create profile record
|
||||
const { error: profileError } = await supabase.from('profiles').upsert({
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
display_name: email.split('@')[0] || 'User',
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="w-full max-w-md space-y-8 text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
WebVNWrite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Complete your account setup
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
}
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<SignupForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
type LoadingSpinnerProps = {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
message?: string
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({ size = 'md', message }: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<svg
|
||||
className={`animate-spin text-blue-500 ${sizeClasses[size]}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{message && (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,12 @@ export default function Navbar({ userEmail, isAdmin }: NavbarProps) {
|
||||
Invite User
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/dashboard/settings"
|
||||
className="text-sm font-medium text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{userEmail}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function PasswordResetModal() {
|
||||
const router = useRouter()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tokenError, setTokenError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleTokenFromUrl = async () => {
|
||||
const hash = window.location.hash
|
||||
if (!hash) return
|
||||
|
||||
const params = new URLSearchParams(hash.substring(1))
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const type = params.get('type')
|
||||
|
||||
if (accessToken && refreshToken && type === 'recovery') {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setTokenError('Invalid or expired reset link. Please request a new password reset.')
|
||||
return
|
||||
}
|
||||
|
||||
// Clear hash from URL without reloading
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.search)
|
||||
setIsOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
handleTokenFromUrl()
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
await supabase.auth.signOut()
|
||||
|
||||
setIsOpen(false)
|
||||
router.push('/login?message=password_reset_success')
|
||||
}
|
||||
|
||||
if (tokenError) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Reset link expired
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{tokenError}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setTokenError(null)}
|
||||
className="mt-4 w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Set new password
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-3 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="reset-password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="reset-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="reset-confirm-password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="reset-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{loading ? 'Updating password...' : 'Update password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,8 +8,10 @@ interface ProjectCardProps {
|
||||
id: string
|
||||
name: string
|
||||
updatedAt: string
|
||||
onDelete: (id: string) => void
|
||||
onRename: (id: string, newName: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
onRename?: (id: string, newName: string) => void
|
||||
shared?: boolean
|
||||
sharedRole?: string
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
@@ -29,6 +31,8 @@ export default function ProjectCard({
|
||||
updatedAt,
|
||||
onDelete,
|
||||
onRename,
|
||||
shared,
|
||||
sharedRole,
|
||||
}: ProjectCardProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
@@ -62,7 +66,7 @@ export default function ProjectCard({
|
||||
|
||||
setIsDeleting(false)
|
||||
setShowDeleteDialog(false)
|
||||
onDelete(id)
|
||||
onDelete?.(id)
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
@@ -106,7 +110,7 @@ export default function ProjectCard({
|
||||
|
||||
setIsRenaming(false)
|
||||
setShowRenameDialog(false)
|
||||
onRename(id, newName.trim())
|
||||
onRename?.(id, newName.trim())
|
||||
}
|
||||
|
||||
const handleCancelRename = () => {
|
||||
@@ -122,6 +126,7 @@ export default function ProjectCard({
|
||||
onClick={handleCardClick}
|
||||
className="group relative cursor-pointer rounded-lg border border-zinc-200 bg-white p-6 transition-all hover:border-blue-300 hover:shadow-md dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-blue-600"
|
||||
>
|
||||
{!shared && onRename && onDelete && (
|
||||
<div className="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={handleRenameClick}
|
||||
@@ -162,6 +167,14 @@ export default function ProjectCard({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{shared && (
|
||||
<div className="absolute right-3 top-3">
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{sharedRole === 'editor' ? 'Editor' : 'Viewer'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="pr-8 text-lg font-semibold text-zinc-900 group-hover:text-blue-600 dark:text-zinc-50 dark:group-hover:text-blue-400">
|
||||
{name}
|
||||
</h2>
|
||||
|
||||
@@ -6,16 +6,23 @@ interface ToastProps {
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
onClose: () => void
|
||||
action?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
}
|
||||
|
||||
export default function Toast({ message, type, onClose }: ToastProps) {
|
||||
export default function Toast({ message, type, onClose, action }: ToastProps) {
|
||||
useEffect(() => {
|
||||
// Don't auto-dismiss if there's an action button
|
||||
if (action) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
onClose()
|
||||
}, 3000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [onClose])
|
||||
}, [onClose, action])
|
||||
|
||||
const bgColor =
|
||||
type === 'success'
|
||||
@@ -60,6 +67,14 @@ export default function Toast({ message, type, onClose }: ToastProps) {
|
||||
>
|
||||
{icon}
|
||||
<span>{message}</span>
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
className="ml-2 rounded bg-white/20 px-2 py-0.5 text-xs font-semibold hover:bg-white/30"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-2 rounded p-0.5 hover:bg-white/20"
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import RevertConfirmDialog from './RevertConfirmDialog'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export type AuditEntry = {
|
||||
id: string
|
||||
project_id: string
|
||||
user_id: string
|
||||
action_type: string
|
||||
entity_id: string
|
||||
previous_state: Record<string, unknown> | null
|
||||
new_state: Record<string, unknown> | null
|
||||
created_at: string
|
||||
user_display_name?: string
|
||||
}
|
||||
|
||||
type ActivityHistorySidebarProps = {
|
||||
projectId: string
|
||||
onClose: () => void
|
||||
onSelectEntity: (entityId: string, actionType: string) => void
|
||||
onRevert: (entry: AuditEntry) => void
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
node_add: 'Added node',
|
||||
node_update: 'Updated node',
|
||||
node_delete: 'Deleted node',
|
||||
edge_add: 'Added edge',
|
||||
edge_update: 'Updated edge',
|
||||
edge_delete: 'Deleted edge',
|
||||
}
|
||||
|
||||
const ACTION_ICONS: Record<string, string> = {
|
||||
node_add: '+',
|
||||
node_update: '~',
|
||||
node_delete: '-',
|
||||
edge_add: '+',
|
||||
edge_update: '~',
|
||||
edge_delete: '-',
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
node_add: 'text-green-500',
|
||||
node_update: 'text-blue-500',
|
||||
node_delete: 'text-red-500',
|
||||
edge_add: 'text-green-500',
|
||||
edge_update: 'text-blue-500',
|
||||
edge_delete: 'text-red-500',
|
||||
}
|
||||
|
||||
// Same hash logic as PresenceAvatars and FlowchartEditor
|
||||
const PRESENCE_COLORS = [
|
||||
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||
]
|
||||
|
||||
function getUserColor(userId: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
|
||||
}
|
||||
return PRESENCE_COLORS[Math.abs(hash) % PRESENCE_COLORS.length]
|
||||
}
|
||||
|
||||
function getEntityDescription(entry: AuditEntry): string {
|
||||
const state = entry.new_state || entry.previous_state
|
||||
if (!state) return entry.entity_id.slice(0, 8)
|
||||
|
||||
if (entry.action_type.startsWith('node_')) {
|
||||
const type = (state as Record<string, unknown>).type as string | undefined
|
||||
const data = (state as Record<string, unknown>).data as Record<string, unknown> | undefined
|
||||
if (type === 'dialogue' && data) {
|
||||
const speaker = (data.speaker as string) || (data.characterId as string) || ''
|
||||
return speaker ? `Dialogue (${speaker})` : 'Dialogue node'
|
||||
}
|
||||
if (type === 'choice' && data) {
|
||||
const question = (data.question as string) || ''
|
||||
return question ? `Choice: "${question.slice(0, 20)}${question.length > 20 ? '…' : ''}"` : 'Choice node'
|
||||
}
|
||||
if (type === 'variable' && data) {
|
||||
const name = (data.variableName as string) || ''
|
||||
return name ? `Variable: ${name}` : 'Variable node'
|
||||
}
|
||||
return `${type || 'Unknown'} node`
|
||||
}
|
||||
|
||||
// Edge entries
|
||||
return 'Connection'
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function getTimePeriod(dateStr: string): 'Today' | 'Yesterday' | 'Earlier' {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
if (date >= today) return 'Today'
|
||||
if (date >= yesterday) return 'Yesterday'
|
||||
return 'Earlier'
|
||||
}
|
||||
|
||||
type GroupedEntries = {
|
||||
period: 'Today' | 'Yesterday' | 'Earlier'
|
||||
entries: AuditEntry[]
|
||||
}
|
||||
|
||||
function groupByPeriod(entries: AuditEntry[]): GroupedEntries[] {
|
||||
const groups: Record<string, AuditEntry[]> = {}
|
||||
const order: ('Today' | 'Yesterday' | 'Earlier')[] = ['Today', 'Yesterday', 'Earlier']
|
||||
|
||||
for (const entry of entries) {
|
||||
const period = getTimePeriod(entry.created_at)
|
||||
if (!groups[period]) groups[period] = []
|
||||
groups[period].push(entry)
|
||||
}
|
||||
|
||||
return order
|
||||
.filter((p) => groups[p] && groups[p].length > 0)
|
||||
.map((p) => ({ period: p, entries: groups[p] }))
|
||||
}
|
||||
|
||||
export default function ActivityHistorySidebar({
|
||||
projectId,
|
||||
onClose,
|
||||
onSelectEntity,
|
||||
onRevert,
|
||||
}: ActivityHistorySidebarProps) {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [revertEntry, setRevertEntry] = useState<AuditEntry | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const fetchEntriesRaw = useCallback(async (offset: number): Promise<{ entries: AuditEntry[]; hasMore: boolean; error?: string }> => {
|
||||
const supabase = createClient()
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('audit_trail')
|
||||
.select('id, project_id, user_id, action_type, entity_id, previous_state, new_state, created_at')
|
||||
.eq('project_id', projectId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + PAGE_SIZE - 1)
|
||||
|
||||
if (fetchError) {
|
||||
return { entries: [], hasMore: false, error: fetchError.message }
|
||||
}
|
||||
|
||||
const moreAvailable = !!data && data.length >= PAGE_SIZE
|
||||
|
||||
// Fetch user display names for the entries
|
||||
if (data && data.length > 0) {
|
||||
const userIds = [...new Set(data.map((e) => e.user_id))]
|
||||
const { data: profiles } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, display_name')
|
||||
.in('id', userIds)
|
||||
|
||||
const nameMap = new Map<string, string>()
|
||||
if (profiles) {
|
||||
for (const p of profiles) {
|
||||
nameMap.set(p.id, p.display_name || 'Unknown')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries: data.map((e) => ({
|
||||
...e,
|
||||
user_display_name: nameMap.get(e.user_id) || 'Unknown',
|
||||
})),
|
||||
hasMore: moreAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
return { entries: data || [], hasMore: moreAvailable }
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
const controller = new AbortController()
|
||||
fetchEntriesRaw(0).then((result) => {
|
||||
if (!controller.signal.aborted && mountedRef.current) {
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
setEntries(result.entries)
|
||||
setHasMore(result.hasMore)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
controller.abort()
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [fetchEntriesRaw])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
setLoadingMore(true)
|
||||
const result = await fetchEntriesRaw(entries.length)
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
setEntries((prev) => [...prev, ...result.entries])
|
||||
setHasMore(result.hasMore)
|
||||
}
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const grouped = groupByPeriod(entries)
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 top-0 z-40 flex h-full w-80 flex-col border-l border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Activity History
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||
title="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<svg className="h-5 w-5 animate-spin text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-3 text-sm text-red-500 dark:text-red-400">
|
||||
Failed to load history: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && entries.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||
No activity recorded yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && grouped.map((group) => (
|
||||
<div key={group.period}>
|
||||
<div className="sticky top-0 bg-zinc-50 px-4 py-1.5 text-xs font-medium uppercase tracking-wide text-zinc-500 dark:bg-zinc-850 dark:text-zinc-400" style={{ backgroundColor: 'inherit' }}>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800">
|
||||
{group.period}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-100 dark:divide-zinc-700/50">
|
||||
{group.entries.map((entry) => {
|
||||
const userColor = getUserColor(entry.user_id)
|
||||
const isDeleted = entry.action_type.endsWith('_delete')
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`group w-full px-4 py-2.5 text-left transition-colors ${
|
||||
isDeleted
|
||||
? 'opacity-60'
|
||||
: 'hover:bg-zinc-50 dark:hover:bg-zinc-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className="mt-1 h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: userColor }}
|
||||
title={entry.user_display_name}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-xs font-bold ${ACTION_COLORS[entry.action_type] || 'text-zinc-500'}`}>
|
||||
{ACTION_ICONS[entry.action_type] || '?'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => !isDeleted && onSelectEntity(entry.entity_id, entry.action_type)}
|
||||
disabled={isDeleted}
|
||||
className={`truncate text-xs font-medium text-zinc-800 dark:text-zinc-200 ${!isDeleted ? 'hover:underline' : ''}`}
|
||||
>
|
||||
{ACTION_LABELS[entry.action_type] || entry.action_type}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{getEntityDescription(entry)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||
<span>{entry.user_display_name}</span>
|
||||
<span>·</span>
|
||||
<span>{formatTime(entry.created_at)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRevertEntry(entry)
|
||||
}}
|
||||
className="hidden rounded px-1.5 py-0.5 text-[10px] font-medium text-amber-600 hover:bg-amber-50 group-hover:inline-block dark:text-amber-400 dark:hover:bg-amber-900/30"
|
||||
title="Revert this change"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && hasMore && entries.length > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-600 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||
>
|
||||
{loadingMore ? 'Loading...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{revertEntry && (
|
||||
<RevertConfirmDialog
|
||||
actionType={revertEntry.action_type}
|
||||
entityDescription={getEntityDescription(revertEntry)}
|
||||
previousState={revertEntry.previous_state}
|
||||
newState={revertEntry.new_state}
|
||||
onConfirm={() => {
|
||||
onRevert(revertEntry)
|
||||
setRevertEntry(null)
|
||||
}}
|
||||
onCancel={() => setRevertEntry(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export type CollaborationNotification = {
|
||||
id: string
|
||||
displayName: string
|
||||
type: 'join' | 'leave'
|
||||
color: string
|
||||
}
|
||||
|
||||
type CollaborationToastProps = {
|
||||
notification: CollaborationNotification
|
||||
onDismiss: (id: string) => void
|
||||
}
|
||||
|
||||
export default function CollaborationToast({ notification, onDismiss }: CollaborationToastProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(notification.id)
|
||||
}, 3000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [notification.id, onDismiss])
|
||||
|
||||
const message = notification.type === 'join'
|
||||
? `${notification.displayName} joined`
|
||||
: `${notification.displayName} left`
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg bg-zinc-800 px-4 py-2.5 text-sm font-medium text-white shadow-lg dark:bg-zinc-700"
|
||||
>
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: notification.color }}
|
||||
/>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type ConditionEditorProps = {
|
||||
edgeId: string
|
||||
condition: Condition | undefined
|
||||
onChange: (edgeId: string, condition: Condition | undefined) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ConditionEditor({
|
||||
edgeId,
|
||||
condition,
|
||||
onChange,
|
||||
onClose,
|
||||
}: ConditionEditorProps) {
|
||||
const { variables, onAddVariable } = useEditorContext()
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
|
||||
|
||||
const variableItems: ComboboxItem[] = useMemo(
|
||||
() =>
|
||||
variables.map((v) => ({
|
||||
id: v.id,
|
||||
label: v.name,
|
||||
badge: v.type,
|
||||
})),
|
||||
[variables]
|
||||
)
|
||||
|
||||
const selectedVariable = useMemo(() => {
|
||||
if (!condition?.variableId) return undefined
|
||||
return variables.find((v) => v.id === condition.variableId)
|
||||
}, [condition?.variableId, variables])
|
||||
|
||||
const hasInvalidReference = useMemo(() => {
|
||||
if (!condition?.variableId) return false
|
||||
return !variables.some((v) => v.id === condition.variableId)
|
||||
}, [condition?.variableId, variables])
|
||||
|
||||
// Determine operators based on variable type
|
||||
const availableOperators = useMemo(() => {
|
||||
if (!selectedVariable || selectedVariable.type === 'numeric') {
|
||||
return [
|
||||
{ value: '==', label: '==' },
|
||||
{ value: '!=', label: '!=' },
|
||||
{ value: '>', label: '>' },
|
||||
{ value: '<', label: '<' },
|
||||
{ value: '>=', label: '>=' },
|
||||
{ value: '<=', label: '<=' },
|
||||
] as const
|
||||
}
|
||||
// string and boolean only support == and !=
|
||||
return [
|
||||
{ value: '==', label: '==' },
|
||||
{ value: '!=', label: '!=' },
|
||||
] as const
|
||||
}, [selectedVariable])
|
||||
|
||||
const handleVariableSelect = useCallback(
|
||||
(variableId: string) => {
|
||||
const variable = variables.find((v) => v.id === variableId)
|
||||
const defaultValue = variable
|
||||
? variable.type === 'numeric'
|
||||
? 0
|
||||
: variable.type === 'boolean'
|
||||
? false
|
||||
: ''
|
||||
: 0
|
||||
// Reset operator if current one is not valid for new type
|
||||
const validOperator =
|
||||
variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator)
|
||||
? '=='
|
||||
: condition?.operator || '=='
|
||||
|
||||
onChange(edgeId, {
|
||||
variableName: variable?.name || '',
|
||||
variableId,
|
||||
operator: validOperator as Condition['operator'],
|
||||
value: defaultValue,
|
||||
})
|
||||
},
|
||||
[variables, condition?.operator, edgeId, onChange]
|
||||
)
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(operator: string) => {
|
||||
if (!condition) return
|
||||
onChange(edgeId, {
|
||||
...condition,
|
||||
operator: operator as Condition['operator'],
|
||||
})
|
||||
},
|
||||
[condition, edgeId, onChange]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: number | string | boolean) => {
|
||||
if (!condition) return
|
||||
onChange(edgeId, {
|
||||
...condition,
|
||||
value,
|
||||
})
|
||||
},
|
||||
[condition, edgeId, onChange]
|
||||
)
|
||||
|
||||
const handleRemoveCondition = useCallback(() => {
|
||||
onChange(edgeId, undefined)
|
||||
onClose()
|
||||
}, [edgeId, onChange, onClose])
|
||||
|
||||
const handleAddNew = useCallback(() => {
|
||||
setShowAddForm(true)
|
||||
setNewName('')
|
||||
setNewType('numeric')
|
||||
}, [])
|
||||
|
||||
const handleSubmitNew = useCallback(() => {
|
||||
if (!newName.trim()) return
|
||||
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
|
||||
const newId = onAddVariable(newName.trim(), newType, defaultValue)
|
||||
onChange(edgeId, {
|
||||
variableName: newName.trim(),
|
||||
variableId: newId,
|
||||
operator: '==',
|
||||
value: defaultValue,
|
||||
})
|
||||
setShowAddForm(false)
|
||||
}, [newName, newType, onAddVariable, edgeId, onChange])
|
||||
|
||||
const handleCancelNew = useCallback(() => {
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
// Render value input based on variable type
|
||||
const renderValueInput = () => {
|
||||
const varType = selectedVariable?.type || 'numeric'
|
||||
|
||||
if (varType === 'boolean') {
|
||||
return (
|
||||
<select
|
||||
value={String(condition?.value ?? false)}
|
||||
onChange={(e) => handleValueChange(e.target.value === 'true')}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (varType === 'string') {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={String(condition?.value ?? '')}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
placeholder="Value..."
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// numeric
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={typeof condition?.value === 'number' ? condition.value : 0}
|
||||
onChange={(e) => handleValueChange(parseFloat(e.target.value) || 0)}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-4 shadow-xl dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
Edge Condition
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Variable selector */}
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Variable
|
||||
</label>
|
||||
<div className={hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}>
|
||||
<Combobox
|
||||
items={variableItems}
|
||||
value={condition?.variableId}
|
||||
onChange={handleVariableSelect}
|
||||
placeholder="Select variable..."
|
||||
onAddNew={handleAddNew}
|
||||
/>
|
||||
</div>
|
||||
{hasInvalidReference && (
|
||||
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||
Variable not found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline add form */}
|
||||
{showAddForm && (
|
||||
<div className="mb-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
New variable
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSubmitNew()
|
||||
if (e.key === 'Escape') handleCancelNew()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
|
||||
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
<option value="numeric">numeric</option>
|
||||
<option value="string">string</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end gap-1">
|
||||
<button
|
||||
onClick={handleCancelNew}
|
||||
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitNew}
|
||||
disabled={!newName.trim()}
|
||||
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Operator and value (shown when variable is selected) */}
|
||||
{condition?.variableId && (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Operator
|
||||
</label>
|
||||
<select
|
||||
value={condition.operator || '=='}
|
||||
onChange={(e) => handleOperatorChange(e.target.value)}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
{availableOperators.map((op) => (
|
||||
<option key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Value
|
||||
</label>
|
||||
{renderValueInput()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
{condition?.variableId ? (
|
||||
<button
|
||||
onClick={handleRemoveCondition}
|
||||
className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Remove condition
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
export type ContextMenuType = 'canvas' | 'node' | 'edge'
|
||||
|
||||
type ContextMenuProps = {
|
||||
x: number
|
||||
y: number
|
||||
type: ContextMenuType
|
||||
onClose: () => void
|
||||
onAddDialogue?: () => void
|
||||
onAddChoice?: () => void
|
||||
onAddVariable?: () => void
|
||||
onDelete?: () => void
|
||||
onAddCondition?: () => void
|
||||
}
|
||||
|
||||
export default function ContextMenu({
|
||||
x,
|
||||
y,
|
||||
type,
|
||||
onClose,
|
||||
onAddDialogue,
|
||||
onAddChoice,
|
||||
onAddVariable,
|
||||
onDelete,
|
||||
onAddCondition,
|
||||
}: ContextMenuProps) {
|
||||
// Close menu on Escape key
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
|
||||
// Close menu on click outside
|
||||
const handleClickOutside = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [handleKeyDown, handleClickOutside])
|
||||
|
||||
const menuItemClass =
|
||||
'w-full px-4 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 min-w-40 rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800"
|
||||
style={{ left: x, top: y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{type === 'canvas' && (
|
||||
<>
|
||||
<button
|
||||
className={`${menuItemClass} text-blue-600 dark:text-blue-400`}
|
||||
onClick={() => {
|
||||
onAddDialogue?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Add Dialogue
|
||||
</button>
|
||||
<button
|
||||
className={`${menuItemClass} text-green-600 dark:text-green-400`}
|
||||
onClick={() => {
|
||||
onAddChoice?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Add Choice
|
||||
</button>
|
||||
<button
|
||||
className={`${menuItemClass} text-orange-600 dark:text-orange-400`}
|
||||
onClick={() => {
|
||||
onAddVariable?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Add Variable
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'node' && (
|
||||
<button
|
||||
className={`${menuItemClass} text-red-600 dark:text-red-400`}
|
||||
onClick={() => {
|
||||
onDelete?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
|
||||
{type === 'edge' && (
|
||||
<>
|
||||
<button
|
||||
className={`${menuItemClass} text-red-600 dark:text-red-400`}
|
||||
onClick={() => {
|
||||
onDelete?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
onAddCondition?.()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Add Condition
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { Character, Variable } from '@/types/flowchart'
|
||||
import type { NodeLock } from '@/lib/collaboration/realtime'
|
||||
|
||||
export type NodeLockInfo = NodeLock & { color: string }
|
||||
|
||||
type EditorContextValue = {
|
||||
characters: Character[]
|
||||
onAddCharacter: (name: string, color: string) => string // returns new character id
|
||||
variables: Variable[]
|
||||
onAddVariable: (name: string, type: 'numeric' | 'string' | 'boolean', initialValue: number | string | boolean) => string // returns new variable id
|
||||
nodeLocks: Map<string, NodeLockInfo> // nodeId -> lock info
|
||||
onNodeFocus: (nodeId: string) => void
|
||||
onNodeBlur: () => void
|
||||
}
|
||||
|
||||
const EditorContext = createContext<EditorContextValue>({
|
||||
characters: [],
|
||||
onAddCharacter: () => '',
|
||||
variables: [],
|
||||
onAddVariable: () => '',
|
||||
nodeLocks: new Map(),
|
||||
onNodeFocus: () => {},
|
||||
onNodeBlur: () => {},
|
||||
})
|
||||
|
||||
export const EditorProvider = EditorContext.Provider
|
||||
|
||||
export function useEditorContext() {
|
||||
return useContext(EditorContext)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
export type ValidationIssue = {
|
||||
nodeId: string
|
||||
nodeType: 'dialogue' | 'choice' | 'variable' | 'edge'
|
||||
contentSnippet: string
|
||||
undefinedReference: string
|
||||
referenceType: 'character' | 'variable'
|
||||
}
|
||||
|
||||
type ExportValidationModalProps = {
|
||||
issues: ValidationIssue[]
|
||||
onExportAnyway: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ExportValidationModal({
|
||||
issues,
|
||||
onExportAnyway,
|
||||
onCancel,
|
||||
}: ExportValidationModalProps) {
|
||||
const characterIssues = issues.filter((i) => i.referenceType === 'character')
|
||||
const variableIssues = issues.filter((i) => i.referenceType === 'variable')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="mx-4 w-full max-w-lg rounded-lg bg-white shadow-xl dark:bg-zinc-800">
|
||||
<div className="border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="h-5 w-5 text-orange-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Export Validation Issues
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{issues.length} undefined reference{issues.length !== 1 ? 's' : ''} found. These nodes/edges reference characters or variables that no longer exist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto px-6 py-4">
|
||||
{characterIssues.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
Undefined Characters
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{characterIssues.map((issue, idx) => (
|
||||
<li
|
||||
key={`char-${idx}`}
|
||||
className="rounded border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
{issue.nodeType}
|
||||
</span>
|
||||
<p className="mt-1 truncate text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{issue.contentSnippet}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-orange-600 dark:text-orange-400">
|
||||
ID: {issue.undefinedReference.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{variableIssues.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
Undefined Variables
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{variableIssues.map((issue, idx) => (
|
||||
<li
|
||||
key={`var-${idx}`}
|
||||
className="rounded border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
{issue.nodeType}
|
||||
</span>
|
||||
<p className="mt-1 truncate text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{issue.contentSnippet}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-orange-600 dark:text-orange-400">
|
||||
ID: {issue.undefinedReference.slice(0, 8)}...
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onExportAnyway}
|
||||
className="rounded bg-orange-500 px-4 py-2 text-sm font-medium text-white hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Export anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { Character, Variable } from '@/types/flowchart'
|
||||
|
||||
type ImportMode = 'characters' | 'variables'
|
||||
|
||||
type ProjectListItem = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type ImportFromProjectModalProps = {
|
||||
mode: ImportMode
|
||||
currentProjectId: string
|
||||
existingCharacters: Character[]
|
||||
existingVariables: Variable[]
|
||||
onImportCharacters: (characters: Character[]) => void
|
||||
onImportVariables: (variables: Variable[]) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ImportFromProjectModal({
|
||||
mode,
|
||||
currentProjectId,
|
||||
existingCharacters,
|
||||
existingVariables,
|
||||
onImportCharacters,
|
||||
onImportVariables,
|
||||
onClose,
|
||||
}: ImportFromProjectModalProps) {
|
||||
const [projects, setProjects] = useState<ProjectListItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
|
||||
const [sourceCharacters, setSourceCharacters] = useState<Character[]>([])
|
||||
const [sourceVariables, setSourceVariables] = useState<Variable[]>([])
|
||||
const [loadingSource, setLoadingSource] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [warnings, setWarnings] = useState<string[]>([])
|
||||
|
||||
// Load user's projects on mount
|
||||
useEffect(() => {
|
||||
async function fetchProjects() {
|
||||
const supabase = createClient()
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name')
|
||||
.neq('id', currentProjectId)
|
||||
.order('name')
|
||||
|
||||
if (fetchError) {
|
||||
setError('Failed to load projects')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setProjects(data || [])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchProjects()
|
||||
}, [currentProjectId])
|
||||
|
||||
// Load source project's characters/variables when a project is selected
|
||||
const handleSelectProject = async (projectId: string) => {
|
||||
setSelectedProjectId(projectId)
|
||||
setLoadingSource(true)
|
||||
setWarnings([])
|
||||
setSelectedIds(new Set())
|
||||
|
||||
const supabase = createClient()
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('projects')
|
||||
.select('flowchart_data')
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !data) {
|
||||
setError('Failed to load project data')
|
||||
setLoadingSource(false)
|
||||
return
|
||||
}
|
||||
|
||||
const flowchartData = data.flowchart_data || {}
|
||||
const chars: Character[] = flowchartData.characters || []
|
||||
const vars: Variable[] = flowchartData.variables || []
|
||||
|
||||
setSourceCharacters(chars)
|
||||
setSourceVariables(vars)
|
||||
setLoadingSource(false)
|
||||
|
||||
// Select all by default
|
||||
const items = mode === 'characters' ? chars : vars
|
||||
setSelectedIds(new Set(items.map((item) => item.id)))
|
||||
}
|
||||
|
||||
const handleToggleItem = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const items = mode === 'characters' ? sourceCharacters : sourceVariables
|
||||
setSelectedIds(new Set(items.map((item) => item.id)))
|
||||
}
|
||||
|
||||
const handleSelectNone = () => {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
const importWarnings: string[] = []
|
||||
|
||||
if (mode === 'characters') {
|
||||
const selectedCharacters = sourceCharacters.filter((c) => selectedIds.has(c.id))
|
||||
const existingNames = new Set(existingCharacters.map((c) => c.name.toLowerCase()))
|
||||
const toImport: Character[] = []
|
||||
|
||||
for (const char of selectedCharacters) {
|
||||
if (existingNames.has(char.name.toLowerCase())) {
|
||||
importWarnings.push(`Skipped "${char.name}" (already exists)`)
|
||||
} else {
|
||||
// Create new ID to avoid conflicts
|
||||
toImport.push({ ...char, id: nanoid() })
|
||||
existingNames.add(char.name.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
if (importWarnings.length > 0) {
|
||||
setWarnings(importWarnings)
|
||||
}
|
||||
|
||||
if (toImport.length > 0) {
|
||||
onImportCharacters(toImport)
|
||||
}
|
||||
|
||||
if (importWarnings.length === 0) {
|
||||
onClose()
|
||||
}
|
||||
} else {
|
||||
const selectedVariables = sourceVariables.filter((v) => selectedIds.has(v.id))
|
||||
const existingNames = new Set(existingVariables.map((v) => v.name.toLowerCase()))
|
||||
const toImport: Variable[] = []
|
||||
|
||||
for (const variable of selectedVariables) {
|
||||
if (existingNames.has(variable.name.toLowerCase())) {
|
||||
importWarnings.push(`Skipped "${variable.name}" (already exists)`)
|
||||
} else {
|
||||
// Create new ID to avoid conflicts
|
||||
toImport.push({ ...variable, id: nanoid() })
|
||||
existingNames.add(variable.name.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
if (importWarnings.length > 0) {
|
||||
setWarnings(importWarnings)
|
||||
}
|
||||
|
||||
if (toImport.length > 0) {
|
||||
onImportVariables(toImport)
|
||||
}
|
||||
|
||||
if (importWarnings.length === 0) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items = mode === 'characters' ? sourceCharacters : sourceVariables
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative flex max-h-[80vh] w-full max-w-lg flex-col rounded-lg bg-white shadow-xl dark:bg-zinc-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Import {mode === 'characters' ? 'Characters' : 'Variables'} from Project
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading && (
|
||||
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Loading projects...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="py-4 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && !selectedProjectId && (
|
||||
<>
|
||||
{projects.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||
No other projects found.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="mb-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Select a project to import from:
|
||||
</p>
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => handleSelectProject(project.id)}
|
||||
className="flex w-full items-center rounded-lg border border-zinc-200 px-4 py-3 text-left text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-50 dark:hover:bg-zinc-700/50"
|
||||
>
|
||||
<svg className="mr-3 h-4 w-4 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
{project.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{loadingSource && (
|
||||
<p className="py-8 text-center text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Loading {mode}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedProjectId && !loadingSource && (
|
||||
<>
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => { setSelectedProjectId(null); setWarnings([]) }}
|
||||
className="mb-3 flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to projects
|
||||
</button>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||
This project has no {mode} defined.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Select all/none controls */}
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{selectedIds.size} of {items.length} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectNone}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Select none
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Item list with checkboxes */}
|
||||
<div className="space-y-1">
|
||||
{mode === 'characters'
|
||||
? sourceCharacters.map((char) => (
|
||||
<label
|
||||
key={char.id}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border border-zinc-200 px-4 py-2.5 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-700/50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(char.id)}
|
||||
onChange={() => handleToggleItem(char.id)}
|
||||
className="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600"
|
||||
/>
|
||||
<div
|
||||
className="h-3.5 w-3.5 rounded-full"
|
||||
style={{ backgroundColor: char.color }}
|
||||
/>
|
||||
<span className="text-sm text-zinc-900 dark:text-zinc-50">
|
||||
{char.name}
|
||||
</span>
|
||||
{char.description && (
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
- {char.description}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))
|
||||
: sourceVariables.map((variable) => (
|
||||
<label
|
||||
key={variable.id}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border border-zinc-200 px-4 py-2.5 hover:bg-zinc-50 dark:border-zinc-700 dark:hover:bg-zinc-700/50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(variable.id)}
|
||||
onChange={() => handleToggleItem(variable.id)}
|
||||
className="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600"
|
||||
/>
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
variable.type === 'numeric'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: variable.type === 'string'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
}`}>
|
||||
{variable.type}
|
||||
</span>
|
||||
<span className="text-sm text-zinc-900 dark:text-zinc-50">
|
||||
{variable.name}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
(initial: {String(variable.initialValue)})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="mt-3 rounded-lg border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-900/20">
|
||||
<p className="mb-1 text-xs font-medium text-orange-700 dark:text-orange-300">
|
||||
Import warnings:
|
||||
</p>
|
||||
{warnings.map((warning, i) => (
|
||||
<p key={i} className="text-xs text-orange-600 dark:text-orange-400">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with import button */}
|
||||
{selectedProjectId && !loadingSource && items.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-3 border-t border-zinc-200 px-6 py-3 dark:border-zinc-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Import {selectedIds.size > 0 ? `(${selectedIds.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import type { NodeLock } from '@/lib/collaboration/realtime'
|
||||
|
||||
type NodeLockIndicatorProps = {
|
||||
lock: NodeLock
|
||||
color: string
|
||||
}
|
||||
|
||||
export default function NodeLockIndicator({ lock, color }: NodeLockIndicatorProps) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 rounded-lg border-2"
|
||||
style={{ borderColor: color }}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{lock.displayName}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type OptionConditionEditorProps = {
|
||||
condition: Condition | undefined
|
||||
onChange: (condition: Condition | undefined) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function OptionConditionEditor({
|
||||
condition,
|
||||
onChange,
|
||||
onClose,
|
||||
}: OptionConditionEditorProps) {
|
||||
const { variables, onAddVariable } = useEditorContext()
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
|
||||
|
||||
const variableItems: ComboboxItem[] = useMemo(
|
||||
() =>
|
||||
variables.map((v) => ({
|
||||
id: v.id,
|
||||
label: v.name,
|
||||
badge: v.type,
|
||||
})),
|
||||
[variables]
|
||||
)
|
||||
|
||||
const selectedVariable = useMemo(() => {
|
||||
if (!condition?.variableId) return undefined
|
||||
return variables.find((v) => v.id === condition.variableId)
|
||||
}, [condition?.variableId, variables])
|
||||
|
||||
const hasInvalidReference = useMemo(() => {
|
||||
if (!condition?.variableId) return false
|
||||
return !variables.some((v) => v.id === condition.variableId)
|
||||
}, [condition?.variableId, variables])
|
||||
|
||||
const availableOperators = useMemo(() => {
|
||||
if (!selectedVariable || selectedVariable.type === 'numeric') {
|
||||
return [
|
||||
{ value: '==', label: '==' },
|
||||
{ value: '!=', label: '!=' },
|
||||
{ value: '>', label: '>' },
|
||||
{ value: '<', label: '<' },
|
||||
{ value: '>=', label: '>=' },
|
||||
{ value: '<=', label: '<=' },
|
||||
] as const
|
||||
}
|
||||
return [
|
||||
{ value: '==', label: '==' },
|
||||
{ value: '!=', label: '!=' },
|
||||
] as const
|
||||
}, [selectedVariable])
|
||||
|
||||
const handleVariableSelect = useCallback(
|
||||
(variableId: string) => {
|
||||
const variable = variables.find((v) => v.id === variableId)
|
||||
const defaultValue = variable
|
||||
? variable.type === 'numeric'
|
||||
? 0
|
||||
: variable.type === 'boolean'
|
||||
? false
|
||||
: ''
|
||||
: 0
|
||||
const validOperator =
|
||||
variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator)
|
||||
? '=='
|
||||
: condition?.operator || '=='
|
||||
|
||||
onChange({
|
||||
variableName: variable?.name || '',
|
||||
variableId,
|
||||
operator: validOperator as Condition['operator'],
|
||||
value: defaultValue,
|
||||
})
|
||||
},
|
||||
[variables, condition?.operator, onChange]
|
||||
)
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(operator: string) => {
|
||||
if (!condition) return
|
||||
onChange({
|
||||
...condition,
|
||||
operator: operator as Condition['operator'],
|
||||
})
|
||||
},
|
||||
[condition, onChange]
|
||||
)
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: number | string | boolean) => {
|
||||
if (!condition) return
|
||||
onChange({
|
||||
...condition,
|
||||
value,
|
||||
})
|
||||
},
|
||||
[condition, onChange]
|
||||
)
|
||||
|
||||
const handleRemoveCondition = useCallback(() => {
|
||||
onChange(undefined)
|
||||
onClose()
|
||||
}, [onChange, onClose])
|
||||
|
||||
const handleAddNew = useCallback(() => {
|
||||
setShowAddForm(true)
|
||||
setNewName('')
|
||||
setNewType('numeric')
|
||||
}, [])
|
||||
|
||||
const handleSubmitNew = useCallback(() => {
|
||||
if (!newName.trim()) return
|
||||
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
|
||||
const newId = onAddVariable(newName.trim(), newType, defaultValue)
|
||||
onChange({
|
||||
variableName: newName.trim(),
|
||||
variableId: newId,
|
||||
operator: '==',
|
||||
value: defaultValue,
|
||||
})
|
||||
setShowAddForm(false)
|
||||
}, [newName, newType, onAddVariable, onChange])
|
||||
|
||||
const handleCancelNew = useCallback(() => {
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
const renderValueInput = () => {
|
||||
const varType = selectedVariable?.type || 'numeric'
|
||||
|
||||
if (varType === 'boolean') {
|
||||
return (
|
||||
<select
|
||||
value={String(condition?.value ?? false)}
|
||||
onChange={(e) => handleValueChange(e.target.value === 'true')}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (varType === 'string') {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={String(condition?.value ?? '')}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
placeholder="Value..."
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={typeof condition?.value === 'number' ? condition.value : 0}
|
||||
onChange={(e) => handleValueChange(parseFloat(e.target.value) || 0)}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-4 shadow-xl dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
Option Condition
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Variable selector */}
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Variable
|
||||
</label>
|
||||
<div className={hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}>
|
||||
<Combobox
|
||||
items={variableItems}
|
||||
value={condition?.variableId}
|
||||
onChange={handleVariableSelect}
|
||||
placeholder="Select variable..."
|
||||
onAddNew={handleAddNew}
|
||||
/>
|
||||
</div>
|
||||
{hasInvalidReference && (
|
||||
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||
Variable not found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline add form */}
|
||||
{showAddForm && (
|
||||
<div className="mb-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
New variable
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSubmitNew()
|
||||
if (e.key === 'Escape') handleCancelNew()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
|
||||
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
<option value="numeric">numeric</option>
|
||||
<option value="string">string</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end gap-1">
|
||||
<button
|
||||
onClick={handleCancelNew}
|
||||
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitNew}
|
||||
disabled={!newName.trim()}
|
||||
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Operator and value (shown when variable is selected) */}
|
||||
{condition?.variableId && (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Operator
|
||||
</label>
|
||||
<select
|
||||
value={condition.operator || '=='}
|
||||
onChange={(e) => handleOperatorChange(e.target.value)}
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
{availableOperators.map((op) => (
|
||||
<option key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
Value
|
||||
</label>
|
||||
{renderValueInput()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
{condition?.variableId ? (
|
||||
<button
|
||||
onClick={handleRemoveCondition}
|
||||
className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Remove condition
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import type { PresenceUser } from '@/lib/collaboration/realtime'
|
||||
|
||||
type PresenceAvatarsProps = {
|
||||
users: PresenceUser[]
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 5
|
||||
|
||||
// Generate a consistent color from a user ID hash
|
||||
function getUserColor(userId: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
|
||||
}
|
||||
const colors = [
|
||||
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||
]
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
// Get initials from a display name (first letter of first two words)
|
||||
function getInitials(displayName: string): string {
|
||||
const parts = displayName.trim().split(/\s+/)
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase()
|
||||
}
|
||||
return displayName.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export default function PresenceAvatars({ users }: PresenceAvatarsProps) {
|
||||
if (users.length === 0) return null
|
||||
|
||||
const visibleUsers = users.slice(0, MAX_VISIBLE)
|
||||
const overflow = users.length - MAX_VISIBLE
|
||||
|
||||
return (
|
||||
<div className="flex items-center -space-x-2">
|
||||
{visibleUsers.map((user) => {
|
||||
const color = getUserColor(user.userId)
|
||||
return (
|
||||
<div
|
||||
key={user.userId}
|
||||
title={user.displayName}
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded-full border-2 border-white text-[10px] font-semibold text-white dark:border-zinc-800"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{getInitials(user.displayName)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded-full border-2 border-white bg-zinc-400 text-[10px] font-semibold text-white dark:border-zinc-800 dark:bg-zinc-500"
|
||||
title={`${overflow} more collaborator${overflow > 1 ? 's' : ''}`}
|
||||
>
|
||||
+{overflow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,14 @@
|
||||
import { useState } from 'react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { Character, Variable } from '@/types/flowchart'
|
||||
import ImportFromProjectModal from './ImportFromProjectModal'
|
||||
|
||||
type Tab = 'characters' | 'variables'
|
||||
|
||||
type ImportModalState = { open: boolean; mode: 'characters' | 'variables' }
|
||||
|
||||
type ProjectSettingsModalProps = {
|
||||
projectId: string
|
||||
characters: Character[]
|
||||
variables: Variable[]
|
||||
onCharactersChange: (characters: Character[]) => void
|
||||
@@ -26,6 +30,7 @@ function randomHexColor(): string {
|
||||
}
|
||||
|
||||
export default function ProjectSettingsModal({
|
||||
projectId,
|
||||
characters,
|
||||
variables,
|
||||
onCharactersChange,
|
||||
@@ -35,6 +40,15 @@ export default function ProjectSettingsModal({
|
||||
getVariableUsageCount,
|
||||
}: ProjectSettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('characters')
|
||||
const [importModal, setImportModal] = useState<ImportModalState>({ open: false, mode: 'characters' })
|
||||
|
||||
const handleImportCharacters = (imported: Character[]) => {
|
||||
onCharactersChange([...characters, ...imported])
|
||||
}
|
||||
|
||||
const handleImportVariables = (imported: Variable[]) => {
|
||||
onVariablesChange([...variables, ...imported])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
@@ -90,6 +104,7 @@ export default function ProjectSettingsModal({
|
||||
characters={characters}
|
||||
onChange={onCharactersChange}
|
||||
getUsageCount={getCharacterUsageCount}
|
||||
onImport={() => setImportModal({ open: true, mode: 'characters' })}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'variables' && (
|
||||
@@ -97,10 +112,23 @@ export default function ProjectSettingsModal({
|
||||
variables={variables}
|
||||
onChange={onVariablesChange}
|
||||
getUsageCount={getVariableUsageCount}
|
||||
onImport={() => setImportModal({ open: true, mode: 'variables' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importModal.open && (
|
||||
<ImportFromProjectModal
|
||||
mode={importModal.mode}
|
||||
currentProjectId={projectId}
|
||||
existingCharacters={characters}
|
||||
existingVariables={variables}
|
||||
onImportCharacters={handleImportCharacters}
|
||||
onImportVariables={handleImportVariables}
|
||||
onClose={() => setImportModal({ open: false, mode: importModal.mode })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -110,6 +138,7 @@ type CharactersTabProps = {
|
||||
characters: Character[]
|
||||
onChange: (characters: Character[]) => void
|
||||
getUsageCount: (characterId: string) => number
|
||||
onImport: () => void
|
||||
}
|
||||
|
||||
type CharacterFormData = {
|
||||
@@ -118,7 +147,7 @@ type CharacterFormData = {
|
||||
description: string
|
||||
}
|
||||
|
||||
function CharactersTab({ characters, onChange, getUsageCount }: CharactersTabProps) {
|
||||
function CharactersTab({ characters, onChange, getUsageCount, onImport }: CharactersTabProps) {
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<CharacterFormData>({ name: '', color: randomHexColor(), description: '' })
|
||||
@@ -207,12 +236,20 @@ function CharactersTab({ characters, onChange, getUsageCount }: CharactersTabPro
|
||||
Define characters that can be referenced in dialogue nodes.
|
||||
</p>
|
||||
{!isAdding && !editingId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onImport}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||
>
|
||||
Import from project
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
||||
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Add Character
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -378,6 +415,7 @@ type VariablesTabProps = {
|
||||
variables: Variable[]
|
||||
onChange: (variables: Variable[]) => void
|
||||
getUsageCount: (variableId: string) => number
|
||||
onImport: () => void
|
||||
}
|
||||
|
||||
type VariableType = 'numeric' | 'string' | 'boolean'
|
||||
@@ -406,7 +444,7 @@ function parseInitialValue(type: VariableType, raw: string): number | string | b
|
||||
}
|
||||
}
|
||||
|
||||
function VariablesTab({ variables, onChange, getUsageCount }: VariablesTabProps) {
|
||||
function VariablesTab({ variables, onChange, getUsageCount, onImport }: VariablesTabProps) {
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<VariableFormData>({ name: '', type: 'numeric', initialValue: '0', description: '' })
|
||||
@@ -503,12 +541,20 @@ function VariablesTab({ variables, onChange, getUsageCount }: VariablesTabProps)
|
||||
Define variables that can be referenced in variable nodes and edge conditions.
|
||||
</p>
|
||||
{!isAdding && !editingId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onImport}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||
>
|
||||
Import from project
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
||||
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Add Variable
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useViewport } from 'reactflow'
|
||||
import type { RemoteCursor } from '@/lib/collaboration/realtime'
|
||||
|
||||
type RemoteCursorsProps = {
|
||||
cursors: RemoteCursor[]
|
||||
}
|
||||
|
||||
const FADE_TIMEOUT_MS = 5000
|
||||
|
||||
// Generate a consistent color from a user ID hash (same algorithm as PresenceAvatars)
|
||||
function getUserColor(userId: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0
|
||||
}
|
||||
const colors = [
|
||||
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||
]
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
export default function RemoteCursors({ cursors }: RemoteCursorsProps) {
|
||||
const viewport = useViewport()
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
// Tick every second to update fade states
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 overflow-hidden">
|
||||
{cursors.map((cursor) => {
|
||||
const timeSinceUpdate = now - cursor.lastUpdated
|
||||
const isFaded = timeSinceUpdate > FADE_TIMEOUT_MS
|
||||
if (isFaded) return null
|
||||
|
||||
const opacity = timeSinceUpdate > FADE_TIMEOUT_MS - 1000
|
||||
? Math.max(0, 1 - (timeSinceUpdate - (FADE_TIMEOUT_MS - 1000)) / 1000)
|
||||
: 1
|
||||
|
||||
// Convert flow coordinates to screen coordinates
|
||||
const screenX = cursor.position.x * viewport.zoom + viewport.x
|
||||
const screenY = cursor.position.y * viewport.zoom + viewport.y
|
||||
|
||||
const color = getUserColor(cursor.userId)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cursor.userId}
|
||||
className="absolute left-0 top-0"
|
||||
style={{
|
||||
transform: `translate(${screenX}px, ${screenY}px)`,
|
||||
opacity,
|
||||
transition: 'transform 80ms linear, opacity 300ms ease',
|
||||
}}
|
||||
>
|
||||
{/* Cursor arrow SVG */}
|
||||
<svg
|
||||
width="16"
|
||||
height="20"
|
||||
viewBox="0 0 16 20"
|
||||
fill="none"
|
||||
style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }}
|
||||
>
|
||||
<path
|
||||
d="M0 0L16 12L8 12L6 20L0 0Z"
|
||||
fill={color}
|
||||
/>
|
||||
<path
|
||||
d="M0 0L16 12L8 12L6 20L0 0Z"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{/* User name label */}
|
||||
<div
|
||||
className="absolute left-4 top-4 whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{cursor.displayName}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
type RevertConfirmDialogProps = {
|
||||
actionType: string
|
||||
entityDescription: string
|
||||
previousState: Record<string, unknown> | null
|
||||
newState: Record<string, unknown> | null
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
node_add: 'node addition',
|
||||
node_update: 'node update',
|
||||
node_delete: 'node deletion',
|
||||
edge_add: 'edge addition',
|
||||
edge_update: 'edge update',
|
||||
edge_delete: 'edge deletion',
|
||||
}
|
||||
|
||||
function formatStatePreview(state: Record<string, unknown> | null): string {
|
||||
if (!state) return '(none)'
|
||||
|
||||
const type = state.type as string | undefined
|
||||
const data = state.data as Record<string, unknown> | undefined
|
||||
|
||||
if (type === 'dialogue' && data) {
|
||||
const speaker = (data.speaker as string) || '(no speaker)'
|
||||
const text = (data.text as string) || '(no text)'
|
||||
return `Dialogue: ${speaker}\n"${text.slice(0, 80)}${text.length > 80 ? '…' : ''}"`
|
||||
}
|
||||
if (type === 'choice' && data) {
|
||||
const prompt = (data.prompt as string) || '(no prompt)'
|
||||
const options = (data.options as { label: string }[]) || []
|
||||
const optionLabels = options.map((o) => o.label || '(empty)').join(', ')
|
||||
return `Choice: ${prompt.slice(0, 60)}${prompt.length > 60 ? '…' : ''}\nOptions: ${optionLabels}`
|
||||
}
|
||||
if (type === 'variable' && data) {
|
||||
const name = (data.variableName as string) || '(unnamed)'
|
||||
const op = (data.operation as string) || 'set'
|
||||
const val = data.value ?? 0
|
||||
return `Variable: ${name} ${op} ${val}`
|
||||
}
|
||||
|
||||
// Edge state
|
||||
if (state.source && state.target) {
|
||||
const condition = (state.data as Record<string, unknown> | undefined)?.condition as Record<string, unknown> | undefined
|
||||
if (condition) {
|
||||
return `Edge: ${state.source} → ${state.target}\nCondition: ${condition.variableName} ${condition.operator} ${condition.value}`
|
||||
}
|
||||
return `Edge: ${state.source} → ${state.target}`
|
||||
}
|
||||
|
||||
return JSON.stringify(state, null, 2).slice(0, 200)
|
||||
}
|
||||
|
||||
function getRevertDescription(actionType: string): string {
|
||||
switch (actionType) {
|
||||
case 'node_add':
|
||||
return 'This will delete the node that was added.'
|
||||
case 'node_update':
|
||||
return 'This will restore the node to its previous state.'
|
||||
case 'node_delete':
|
||||
return 'This will re-create the deleted node.'
|
||||
case 'edge_add':
|
||||
return 'This will delete the edge that was added.'
|
||||
case 'edge_update':
|
||||
return 'This will restore the edge to its previous state.'
|
||||
case 'edge_delete':
|
||||
return 'This will re-create the deleted edge.'
|
||||
default:
|
||||
return 'This will undo the change.'
|
||||
}
|
||||
}
|
||||
|
||||
export default function RevertConfirmDialog({
|
||||
actionType,
|
||||
entityDescription,
|
||||
previousState,
|
||||
newState,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: RevertConfirmDialogProps) {
|
||||
const label = ACTION_LABELS[actionType] || actionType
|
||||
const description = getRevertDescription(actionType)
|
||||
|
||||
// For revert, the "before" is the current state (newState) and "after" is what we're restoring to (previousState)
|
||||
const isAddition = actionType.endsWith('_add')
|
||||
const isDeletion = actionType.endsWith('_delete')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div
|
||||
className="mx-4 w-full max-w-lg rounded-lg bg-white shadow-xl dark:bg-zinc-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-zinc-200 px-5 py-4 dark:border-zinc-700">
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Revert {label}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{entityDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4">
|
||||
<p className="mb-3 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{!isAddition && (
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
|
||||
Current State
|
||||
</div>
|
||||
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
|
||||
{formatStatePreview(newState)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{!isDeletion && (
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
|
||||
{isAddition ? 'Will be removed' : 'Restored State'}
|
||||
</div>
|
||||
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
|
||||
{isAddition ? formatStatePreview(newState) : formatStatePreview(previousState)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{isDeletion && (
|
||||
<div className="col-span-2">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
|
||||
Will be restored
|
||||
</div>
|
||||
<pre className="max-h-32 overflow-auto rounded bg-zinc-100 p-2 text-[11px] text-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
|
||||
{formatStatePreview(previousState)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-zinc-200 px-5 py-3 dark:border-zinc-700">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded px-3 py-1.5 text-xs font-medium text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="rounded bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
getCollaborators,
|
||||
inviteCollaborator,
|
||||
updateCollaboratorRole,
|
||||
removeCollaborator,
|
||||
type Collaborator,
|
||||
} from '@/app/editor/[projectId]/actions'
|
||||
|
||||
type ShareModalProps = {
|
||||
projectId: string
|
||||
isOwner: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ShareModal({ projectId, isOwner, onClose }: ShareModalProps) {
|
||||
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<'editor' | 'viewer'>('editor')
|
||||
const [inviting, setInviting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
|
||||
const fetchCollaborators = useCallback(async () => {
|
||||
const result = await getCollaborators(projectId)
|
||||
if (result.success && result.data) {
|
||||
setCollaborators(result.data)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCollaborators()
|
||||
}, [fetchCollaborators])
|
||||
|
||||
const handleInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email.trim()) return
|
||||
|
||||
setInviting(true)
|
||||
setError(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
const result = await inviteCollaborator(projectId, email.trim(), role)
|
||||
if (result.success) {
|
||||
setEmail('')
|
||||
setSuccessMessage('Collaborator invited successfully')
|
||||
fetchCollaborators()
|
||||
} else {
|
||||
setError(result.error || 'Failed to invite collaborator')
|
||||
}
|
||||
setInviting(false)
|
||||
}
|
||||
|
||||
const handleRoleChange = async (collaboratorId: string, newRole: 'editor' | 'viewer') => {
|
||||
setError(null)
|
||||
const result = await updateCollaboratorRole(projectId, collaboratorId, newRole)
|
||||
if (result.success) {
|
||||
setCollaborators((prev) =>
|
||||
prev.map((c) => (c.id === collaboratorId ? { ...c, role: newRole } : c))
|
||||
)
|
||||
} else {
|
||||
setError(result.error || 'Failed to update role')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (collaboratorId: string) => {
|
||||
setError(null)
|
||||
const result = await removeCollaborator(projectId, collaboratorId)
|
||||
if (result.success) {
|
||||
setCollaborators((prev) => prev.filter((c) => c.id !== collaboratorId))
|
||||
} else {
|
||||
setError(result.error || 'Failed to remove collaborator')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative w-full max-w-lg rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Share Project
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Invite Form - only for owners */}
|
||||
{isOwner && (
|
||||
<form onSubmit={handleInvite} className="mb-6">
|
||||
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
|
||||
Invite collaborator by email
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
disabled={inviting}
|
||||
className="flex-1 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as 'editor' | 'viewer')}
|
||||
disabled={inviting}
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50"
|
||||
>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviting || !email.trim()}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{inviting ? 'Inviting...' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMessage && (
|
||||
<div className="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-400">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collaborators List */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
Collaborators
|
||||
</h3>
|
||||
{loading ? (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">Loading...</p>
|
||||
) : collaborators.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
No collaborators yet. Invite someone to get started.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{collaborators.map((collab) => (
|
||||
<div
|
||||
key={collab.id}
|
||||
className="flex items-center justify-between rounded-md border border-zinc-200 p-3 dark:border-zinc-700"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-zinc-900 truncate dark:text-zinc-50">
|
||||
{collab.display_name || collab.email || 'Unknown user'}
|
||||
</p>
|
||||
{collab.display_name && collab.email && (
|
||||
<p className="text-xs text-zinc-500 truncate dark:text-zinc-400">
|
||||
{collab.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-3">
|
||||
{isOwner ? (
|
||||
<>
|
||||
<select
|
||||
value={collab.role}
|
||||
onChange={(e) =>
|
||||
handleRoleChange(collab.id, e.target.value as 'editor' | 'viewer')
|
||||
}
|
||||
className="rounded border border-zinc-300 bg-white px-2 py-1 text-xs text-zinc-700 focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleRemove(collab.id)}
|
||||
className="rounded p-1 text-zinc-400 hover:text-red-600 dark:hover:text-red-400"
|
||||
title="Remove collaborator"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="rounded bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">
|
||||
{collab.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { ConnectionState, PresenceUser } from '@/lib/collaboration/realtime'
|
||||
import PresenceAvatars from './PresenceAvatars'
|
||||
|
||||
type ToolbarProps = {
|
||||
onAddDialogue: () => void
|
||||
onAddChoice: () => void
|
||||
onAddVariable: () => void
|
||||
onSave: () => void
|
||||
isSaving?: boolean
|
||||
onExport: () => void
|
||||
onExportRenpy: () => void
|
||||
onImport: () => void
|
||||
onProjectSettings: () => void
|
||||
onShare: () => void
|
||||
onHistory: () => void
|
||||
connectionState?: ConnectionState
|
||||
presenceUsers?: PresenceUser[]
|
||||
}
|
||||
|
||||
const connectionLabel: Record<ConnectionState, string> = {
|
||||
connecting: 'Connecting…',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
reconnecting: 'Reconnecting…',
|
||||
}
|
||||
|
||||
const connectionColor: Record<ConnectionState, string> = {
|
||||
connecting: 'bg-yellow-400',
|
||||
connected: 'bg-green-400',
|
||||
disconnected: 'bg-red-400',
|
||||
reconnecting: 'bg-yellow-400',
|
||||
}
|
||||
|
||||
export default function Toolbar({
|
||||
@@ -15,13 +39,28 @@ export default function Toolbar({
|
||||
onAddChoice,
|
||||
onAddVariable,
|
||||
onSave,
|
||||
isSaving,
|
||||
onExport,
|
||||
onExportRenpy,
|
||||
onImport,
|
||||
onProjectSettings,
|
||||
onShare,
|
||||
onHistory,
|
||||
connectionState,
|
||||
presenceUsers,
|
||||
}: ToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mr-2 flex items-center gap-1 text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||
title="Back to Dashboard"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</Link>
|
||||
<span className="mr-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
|
||||
Add Node:
|
||||
</span>
|
||||
@@ -46,6 +85,19 @@ export default function Toolbar({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{presenceUsers && presenceUsers.length > 0 && (
|
||||
<div className="mr-2">
|
||||
<PresenceAvatars users={presenceUsers} />
|
||||
</div>
|
||||
)}
|
||||
{connectionState && (
|
||||
<div className="flex items-center gap-1.5 mr-2" title={connectionLabel[connectionState]}>
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full ${connectionColor[connectionState]}${connectionState === 'reconnecting' ? ' animate-pulse' : ''}`} />
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{connectionLabel[connectionState]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onProjectSettings}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
@@ -53,10 +105,45 @@ export default function Toolbar({
|
||||
Project Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
onClick={onShare}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Save
|
||||
Share
|
||||
</button>
|
||||
<button
|
||||
onClick={onHistory}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{isSaving && (
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onExport}
|
||||
@@ -64,6 +151,12 @@ export default function Toolbar({
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={onExportRenpy}
|
||||
className="rounded border border-purple-400 bg-purple-50 px-3 py-1.5 text-sm font-medium text-purple-700 hover:bg-purple-100 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:border-purple-500 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Export to Ren'Py
|
||||
</button>
|
||||
<button
|
||||
onClick={onImport}
|
||||
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
EdgeProps,
|
||||
getSmoothStepPath,
|
||||
} from 'reactflow'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type ConditionalEdgeData = {
|
||||
condition?: Condition
|
||||
}
|
||||
|
||||
export default function ConditionalEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
markerEnd,
|
||||
selected,
|
||||
}: EdgeProps<ConditionalEdgeData>) {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
const hasCondition = !!data?.condition
|
||||
|
||||
// Format condition as readable label
|
||||
const conditionLabel = hasCondition
|
||||
? `${data.condition!.variableName} ${data.condition!.operator} ${data.condition!.value}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
markerEnd={markerEnd}
|
||||
style={{
|
||||
strokeDasharray: hasCondition ? '5 5' : undefined,
|
||||
stroke: selected ? '#3b82f6' : hasCondition ? '#f59e0b' : '#64748b',
|
||||
strokeWidth: selected ? 2 : 1.5,
|
||||
}}
|
||||
/>
|
||||
{conditionLabel && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
className="rounded border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 dark:border-amber-600 dark:bg-amber-900 dark:text-amber-200"
|
||||
>
|
||||
{conditionLabel}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, ChangeEvent } from 'react'
|
||||
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
|
||||
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||
import type { Condition } from '@/types/flowchart'
|
||||
|
||||
type ChoiceOption = {
|
||||
id: string
|
||||
label: string
|
||||
condition?: Condition
|
||||
}
|
||||
|
||||
type ChoiceNodeData = {
|
||||
@@ -19,6 +24,13 @@ const MAX_OPTIONS = 6
|
||||
|
||||
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { variables, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
||||
|
||||
const lockInfo = nodeLocks.get(id)
|
||||
const isLockedByOther = !!lockInfo
|
||||
|
||||
// --- Handlers de Atualização ---
|
||||
|
||||
const updatePrompt = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -54,6 +66,44 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const handleSaveCondition = useCallback((optionId: string, condition: Condition) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: node.data.options.map((opt: ChoiceOption) =>
|
||||
opt.id === optionId ? { ...opt, condition } : opt
|
||||
),
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
)
|
||||
setEditingConditionOptionId(null)
|
||||
}, [id, setNodes])
|
||||
|
||||
const handleRemoveCondition = useCallback((optionId: string) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: node.data.options.map((opt: ChoiceOption) =>
|
||||
opt.id === optionId ? { ...opt, condition: undefined } : opt
|
||||
),
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
)
|
||||
setEditingConditionOptionId(null)
|
||||
}, [id, setNodes])
|
||||
|
||||
const addOption = useCallback(() => {
|
||||
if (data.options.length >= MAX_OPTIONS) return
|
||||
setNodes((nodes) =>
|
||||
@@ -63,10 +113,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: [
|
||||
...node.data.options,
|
||||
{ id: nanoid(), label: '' },
|
||||
],
|
||||
options: [...node.data.options, { id: nanoid(), label: '' }],
|
||||
},
|
||||
}
|
||||
: node
|
||||
@@ -84,9 +131,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
options: node.data.options.filter(
|
||||
(opt: ChoiceOption) => opt.id !== optionId
|
||||
),
|
||||
options: node.data.options.filter((opt: ChoiceOption) => opt.id !== optionId),
|
||||
},
|
||||
}
|
||||
: node
|
||||
@@ -96,68 +141,136 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||
[id, data.options.length, setNodes]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="input"
|
||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||
/>
|
||||
// --- Auxiliares ---
|
||||
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
|
||||
Choice
|
||||
const editingOption = useMemo(() => {
|
||||
if (!editingConditionOptionId) return null
|
||||
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
|
||||
}, [editingConditionOptionId, data.options])
|
||||
|
||||
const hasInvalidConditionReference = useCallback(
|
||||
(option: ChoiceOption) => {
|
||||
if (!option.condition?.variableId) return false
|
||||
return !variables.some((v) => v.id === option.condition!.variableId)
|
||||
},
|
||||
[variables]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onNodeFocus(id)
|
||||
}, [id, onNodeFocus])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative min-w-[240px] rounded-lg border-2 border-green-500 bg-white p-3 shadow-md dark:border-green-400 dark:bg-zinc-900"
|
||||
onFocus={handleFocus}
|
||||
onBlur={onNodeBlur}
|
||||
>
|
||||
{isLockedByOther && (
|
||||
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
||||
)}
|
||||
{isLockedByOther && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
||||
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||
Being edited by {lockInfo.displayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Handle type="target" position={Position.Top} className="!bg-green-500" />
|
||||
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase text-green-600">Choice Node</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={data.prompt || ''}
|
||||
onChange={updatePrompt}
|
||||
placeholder="What do you choose?"
|
||||
className="mb-3 w-full rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
placeholder="Dialogue prompt..."
|
||||
className="mb-3 w-full rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-zinc-700 dark:bg-zinc-800"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{data.options.map((option, index) => (
|
||||
<div key={option.id} className="relative flex items-center gap-1">
|
||||
<div key={option.id} className="group relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={option.label}
|
||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
className="flex-1 rounded border border-zinc-200 px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-zinc-700 dark:bg-zinc-800"
|
||||
/>
|
||||
|
||||
{/* Botão de Condição */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(option.id)}
|
||||
disabled={data.options.length <= MIN_OPTIONS}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
||||
title="Remove option"
|
||||
onClick={() => setEditingConditionOptionId(option.id)}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
option.condition
|
||||
? hasInvalidConditionReference(option)
|
||||
? 'bg-red-100 text-red-600 dark:bg-red-900/30'
|
||||
: 'bg-amber-100 text-amber-600 dark:bg-amber-900/30'
|
||||
: 'text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
×
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => removeOption(option.id)}
|
||||
className="text-zinc-400 hover:text-red-500"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Visualização da Condição */}
|
||||
{option.condition && (
|
||||
<div className={`mt-1 text-[9px] font-mono px-1 rounded ${
|
||||
hasInvalidConditionReference(option) ? 'text-red-500 bg-red-50' : 'text-amber-600 bg-amber-50'
|
||||
}`}>
|
||||
IF: {option.condition.variableName} {option.condition.operator} {option.condition.value}
|
||||
{hasInvalidConditionReference(option) && " (Variable Missing!)"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={`option-${index}`}
|
||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||
style={{
|
||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
||||
}}
|
||||
id={option.id}
|
||||
style={{ left: `${((index + 1) / (data.options.length + 1)) * 100}%` }}
|
||||
className="!bg-green-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
disabled={data.options.length >= MAX_OPTIONS}
|
||||
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
|
||||
title="Add option"
|
||||
className="mt-3 w-full border border-dashed border-zinc-300 py-1 text-[10px] text-zinc-500 hover:bg-zinc-50 disabled:opacity-50 dark:border-zinc-700 dark:hover:bg-zinc-800"
|
||||
>
|
||||
+ Add Option
|
||||
</button>
|
||||
|
||||
{/* Modal de Edição de Condição */}
|
||||
{editingOption && (
|
||||
<OptionConditionEditor
|
||||
condition={editingOption.condition}
|
||||
onChange={(cond) => {
|
||||
if (cond) {
|
||||
handleSaveCondition(editingOption.id, cond)
|
||||
} else {
|
||||
handleRemoveCondition(editingOption.id)
|
||||
}
|
||||
}}
|
||||
onClose={() => setEditingConditionOptionId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, ChangeEvent } from 'react'
|
||||
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||
|
||||
type DialogueNodeData = {
|
||||
speaker?: string
|
||||
characterId?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const RANDOM_COLORS = [
|
||||
'#EF4444', '#F97316', '#F59E0B', '#10B981',
|
||||
'#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6',
|
||||
'#6366F1', '#F43F5E', '#84CC16', '#06B6D4',
|
||||
]
|
||||
|
||||
function randomColor(): string {
|
||||
return RANDOM_COLORS[Math.floor(Math.random() * RANDOM_COLORS.length)]
|
||||
}
|
||||
|
||||
export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { characters, onAddCharacter, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||
|
||||
const lockInfo = nodeLocks.get(id)
|
||||
const isLockedByOther = !!lockInfo
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newColor, setNewColor] = useState(randomColor)
|
||||
|
||||
const characterItems: ComboboxItem[] = useMemo(
|
||||
() =>
|
||||
characters.map((c) => ({
|
||||
id: c.id,
|
||||
label: c.name,
|
||||
color: c.color,
|
||||
})),
|
||||
[characters]
|
||||
)
|
||||
|
||||
const hasInvalidReference = useMemo(() => {
|
||||
if (!data.characterId) return false
|
||||
return !characters.some((c) => c.id === data.characterId)
|
||||
}, [data.characterId, characters])
|
||||
|
||||
const updateNodeData = useCallback(
|
||||
(field: keyof DialogueNodeData, value: string) => {
|
||||
(updates: Partial<DialogueNodeData>) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, [field]: value } }
|
||||
? { ...node, data: { ...node.data, ...updates } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
@@ -24,22 +62,68 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const handleSpeakerChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
updateNodeData('speaker', e.target.value)
|
||||
const handleCharacterSelect = useCallback(
|
||||
(characterId: string) => {
|
||||
const character = characters.find((c) => c.id === characterId)
|
||||
updateNodeData({
|
||||
characterId,
|
||||
speaker: character?.name || '',
|
||||
})
|
||||
},
|
||||
[updateNodeData]
|
||||
[characters, updateNodeData]
|
||||
)
|
||||
|
||||
const handleAddNew = useCallback(() => {
|
||||
setShowAddForm(true)
|
||||
setNewName('')
|
||||
setNewColor(randomColor())
|
||||
}, [])
|
||||
|
||||
const handleSubmitNew = useCallback(() => {
|
||||
if (!newName.trim()) return
|
||||
const newId = onAddCharacter(newName.trim(), newColor)
|
||||
updateNodeData({
|
||||
characterId: newId,
|
||||
speaker: newName.trim(),
|
||||
})
|
||||
setShowAddForm(false)
|
||||
}, [newName, newColor, onAddCharacter, updateNodeData])
|
||||
|
||||
const handleCancelNew = useCallback(() => {
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
updateNodeData('text', e.target.value)
|
||||
updateNodeData({ text: e.target.value })
|
||||
},
|
||||
[updateNodeData]
|
||||
)
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onNodeFocus(id)
|
||||
}, [id, onNodeFocus])
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] rounded-lg border-2 border-blue-500 bg-blue-50 p-3 shadow-md dark:border-blue-400 dark:bg-blue-950">
|
||||
<div
|
||||
className={`relative min-w-[200px] rounded-lg border-2 ${
|
||||
hasInvalidReference
|
||||
? 'border-orange-500 dark:border-orange-400'
|
||||
: 'border-blue-500 dark:border-blue-400'
|
||||
} bg-blue-50 p-3 shadow-md dark:bg-blue-950`}
|
||||
onFocus={handleFocus}
|
||||
onBlur={onNodeBlur}
|
||||
>
|
||||
{isLockedByOther && (
|
||||
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
||||
)}
|
||||
{isLockedByOther && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
||||
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||
Being edited by {lockInfo.displayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
@@ -51,13 +135,63 @@ export default function DialogueNode({ id, data }: NodeProps<DialogueNodeData>)
|
||||
Dialogue
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<Combobox
|
||||
items={characterItems}
|
||||
value={data.characterId}
|
||||
onChange={handleCharacterSelect}
|
||||
placeholder="Select speaker..."
|
||||
onAddNew={handleAddNew}
|
||||
/>
|
||||
{hasInvalidReference && (
|
||||
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||
Character not found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="mb-2 rounded border border-blue-300 bg-white p-2 dark:border-blue-600 dark:bg-zinc-800">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
New character
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={newColor}
|
||||
onChange={(e) => setNewColor(e.target.value)}
|
||||
className="h-6 w-6 shrink-0 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={data.speaker || ''}
|
||||
onChange={handleSpeakerChange}
|
||||
placeholder="Speaker"
|
||||
className="mb-2 w-full rounded border border-blue-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSubmitNew()
|
||||
if (e.key === 'Escape') handleCancelNew()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end gap-1">
|
||||
<button
|
||||
onClick={handleCancelNew}
|
||||
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitNew}
|
||||
disabled={!newName.trim()}
|
||||
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={data.text || ''}
|
||||
|
||||
@@ -1,23 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, ChangeEvent } from 'react'
|
||||
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
|
||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||
import Combobox from '@/components/editor/Combobox'
|
||||
import type { ComboboxItem } from '@/components/editor/Combobox'
|
||||
import { useEditorContext } from '@/components/editor/EditorContext'
|
||||
import NodeLockIndicator from '@/components/editor/NodeLockIndicator'
|
||||
|
||||
type VariableNodeData = {
|
||||
variableName: string
|
||||
variableId?: string
|
||||
operation: 'set' | 'add' | 'subtract'
|
||||
value: number
|
||||
}
|
||||
|
||||
export default function VariableNode({ id, data }: NodeProps<VariableNodeData>) {
|
||||
const { setNodes } = useReactFlow()
|
||||
const { variables, onAddVariable, nodeLocks, onNodeFocus, onNodeBlur } = useEditorContext()
|
||||
|
||||
const updateVariableName = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const lockInfo = nodeLocks.get(id)
|
||||
const isLockedByOther = !!lockInfo
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
|
||||
|
||||
const variableItems: ComboboxItem[] = useMemo(
|
||||
() =>
|
||||
variables.map((v) => ({
|
||||
id: v.id,
|
||||
label: v.name,
|
||||
badge: v.type,
|
||||
})),
|
||||
[variables]
|
||||
)
|
||||
|
||||
const selectedVariable = useMemo(() => {
|
||||
if (!data.variableId) return undefined
|
||||
return variables.find((v) => v.id === data.variableId)
|
||||
}, [data.variableId, variables])
|
||||
|
||||
const hasInvalidReference = useMemo(() => {
|
||||
if (!data.variableId) return false
|
||||
return !variables.some((v) => v.id === data.variableId)
|
||||
}, [data.variableId, variables])
|
||||
|
||||
const updateNodeData = useCallback(
|
||||
(updates: Partial<VariableNodeData>) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, variableName: e.target.value } }
|
||||
? { ...node, data: { ...node.data, ...updates } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
@@ -25,35 +58,85 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
const handleVariableSelect = useCallback(
|
||||
(variableId: string) => {
|
||||
const variable = variables.find((v) => v.id === variableId)
|
||||
const updates: Partial<VariableNodeData> = {
|
||||
variableId,
|
||||
variableName: variable?.name || '',
|
||||
}
|
||||
// Reset operation to 'set' if current operation is not valid for the variable's type
|
||||
if (variable && variable.type !== 'numeric' && (data.operation === 'add' || data.operation === 'subtract')) {
|
||||
updates.operation = 'set'
|
||||
}
|
||||
updateNodeData(updates)
|
||||
},
|
||||
[variables, data.operation, updateNodeData]
|
||||
)
|
||||
|
||||
const handleAddNew = useCallback(() => {
|
||||
setShowAddForm(true)
|
||||
setNewName('')
|
||||
setNewType('numeric')
|
||||
}, [])
|
||||
|
||||
const handleSubmitNew = useCallback(() => {
|
||||
if (!newName.trim()) return
|
||||
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
|
||||
const newId = onAddVariable(newName.trim(), newType, defaultValue)
|
||||
updateNodeData({
|
||||
variableId: newId,
|
||||
variableName: newName.trim(),
|
||||
})
|
||||
setShowAddForm(false)
|
||||
}, [newName, newType, onAddVariable, updateNodeData])
|
||||
|
||||
const handleCancelNew = useCallback(() => {
|
||||
setShowAddForm(false)
|
||||
}, [])
|
||||
|
||||
const updateOperation = useCallback(
|
||||
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, operation: e.target.value as 'set' | 'add' | 'subtract' } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
updateNodeData({ operation: e.target.value as 'set' | 'add' | 'subtract' })
|
||||
},
|
||||
[id, setNodes]
|
||||
[updateNodeData]
|
||||
)
|
||||
|
||||
const updateValue = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseFloat(e.target.value) || 0
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, value } }
|
||||
: node
|
||||
)
|
||||
)
|
||||
updateNodeData({ value })
|
||||
},
|
||||
[id, setNodes]
|
||||
[updateNodeData]
|
||||
)
|
||||
|
||||
// Filter operations based on selected variable type
|
||||
const isNumeric = !selectedVariable || selectedVariable.type === 'numeric'
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
onNodeFocus(id)
|
||||
}, [id, onNodeFocus])
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] rounded-lg border-2 border-orange-500 bg-orange-50 p-3 shadow-md dark:border-orange-400 dark:bg-orange-950">
|
||||
<div
|
||||
className={`relative min-w-[200px] rounded-lg border-2 ${
|
||||
hasInvalidReference
|
||||
? 'border-orange-500 dark:border-orange-400'
|
||||
: 'border-orange-500 dark:border-orange-400'
|
||||
} bg-orange-50 p-3 shadow-md dark:bg-orange-950`}
|
||||
onFocus={handleFocus}
|
||||
onBlur={onNodeBlur}
|
||||
>
|
||||
{isLockedByOther && (
|
||||
<NodeLockIndicator lock={lockInfo} color={lockInfo.color} />
|
||||
)}
|
||||
{isLockedByOther && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-lg bg-black/10">
|
||||
<span className="rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||
Being edited by {lockInfo.displayName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
@@ -65,13 +148,68 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
||||
Variable
|
||||
</div>
|
||||
|
||||
<div className={`mb-2 ${hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}`}>
|
||||
<Combobox
|
||||
items={variableItems}
|
||||
value={data.variableId}
|
||||
onChange={handleVariableSelect}
|
||||
placeholder="Select variable..."
|
||||
onAddNew={handleAddNew}
|
||||
/>
|
||||
{hasInvalidReference && (
|
||||
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
||||
Variable not found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="mb-2 rounded border border-orange-300 bg-white p-2 dark:border-orange-600 dark:bg-zinc-800">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
New variable
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={data.variableName || ''}
|
||||
onChange={updateVariableName}
|
||||
placeholder="variableName"
|
||||
className="mb-2 w-full rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSubmitNew()
|
||||
if (e.key === 'Escape') handleCancelNew()
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
|
||||
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-orange-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
||||
>
|
||||
<option value="numeric">numeric</option>
|
||||
<option value="string">string</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-end gap-1">
|
||||
<button
|
||||
onClick={handleCancelNew}
|
||||
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmitNew}
|
||||
disabled={!newName.trim()}
|
||||
className="rounded bg-orange-600 px-2 py-0.5 text-xs text-white hover:bg-orange-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-2 flex gap-2">
|
||||
<select
|
||||
@@ -80,8 +218,8 @@ export default function VariableNode({ id, data }: NodeProps<VariableNodeData>)
|
||||
className="flex-1 rounded border border-orange-300 bg-white px-2 py-1 text-sm focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-orange-600 dark:bg-zinc-800 dark:text-white"
|
||||
>
|
||||
<option value="set">set</option>
|
||||
<option value="add">add</option>
|
||||
<option value="subtract">subtract</option>
|
||||
{isNumeric && <option value="add">add</option>}
|
||||
{isNumeric && <option value="subtract">subtract</option>}
|
||||
</select>
|
||||
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
||||
|
||||
export type AuditActionType =
|
||||
| 'node_add'
|
||||
| 'node_update'
|
||||
| 'node_delete'
|
||||
| 'edge_add'
|
||||
| 'edge_update'
|
||||
| 'edge_delete'
|
||||
|
||||
type PendingAuditEntry = {
|
||||
actionType: AuditActionType
|
||||
entityId: string
|
||||
previousState: unknown | null
|
||||
newState: unknown | null
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 1000
|
||||
|
||||
export class AuditTrailRecorder {
|
||||
private projectId: string
|
||||
private userId: string
|
||||
private previousNodes: Map<string, string> // node ID -> JSON string
|
||||
private previousEdges: Map<string, string> // edge ID -> JSON string
|
||||
private pending: Map<string, PendingAuditEntry> // entityId -> pending entry
|
||||
private isDestroyed = false
|
||||
|
||||
constructor(projectId: string, userId: string) {
|
||||
this.projectId = projectId
|
||||
this.userId = userId
|
||||
this.previousNodes = new Map()
|
||||
this.previousEdges = new Map()
|
||||
this.pending = new Map()
|
||||
}
|
||||
|
||||
/** Initialize with the current state (no audit entries are created for initial state) */
|
||||
initialize(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
|
||||
this.previousNodes.clear()
|
||||
this.previousEdges.clear()
|
||||
nodes.forEach((node) => {
|
||||
this.previousNodes.set(node.id, JSON.stringify(node))
|
||||
})
|
||||
edges.forEach((edge) => {
|
||||
this.previousEdges.set(edge.id, JSON.stringify(edge))
|
||||
})
|
||||
}
|
||||
|
||||
/** Record changes by diffing current state against previous state */
|
||||
recordNodeChanges(currentNodes: FlowchartNode[]): void {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
const currentMap = new Map<string, string>()
|
||||
currentNodes.forEach((node) => {
|
||||
currentMap.set(node.id, JSON.stringify(node))
|
||||
})
|
||||
|
||||
// Detect additions and updates
|
||||
for (const [id, serialized] of currentMap) {
|
||||
const previous = this.previousNodes.get(id)
|
||||
if (!previous) {
|
||||
// Node was added
|
||||
this.scheduleWrite(id, 'node_add', null, JSON.parse(serialized))
|
||||
} else if (previous !== serialized) {
|
||||
// Node was updated
|
||||
this.scheduleWrite(id, 'node_update', JSON.parse(previous), JSON.parse(serialized))
|
||||
}
|
||||
}
|
||||
|
||||
// Detect deletions
|
||||
for (const [id, serialized] of this.previousNodes) {
|
||||
if (!currentMap.has(id)) {
|
||||
this.scheduleWrite(id, 'node_delete', JSON.parse(serialized), null)
|
||||
}
|
||||
}
|
||||
|
||||
// Update previous state
|
||||
this.previousNodes = currentMap
|
||||
}
|
||||
|
||||
/** Record edge changes by diffing current state against previous state */
|
||||
recordEdgeChanges(currentEdges: FlowchartEdge[]): void {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
const currentMap = new Map<string, string>()
|
||||
currentEdges.forEach((edge) => {
|
||||
currentMap.set(edge.id, JSON.stringify(edge))
|
||||
})
|
||||
|
||||
// Detect additions and updates
|
||||
for (const [id, serialized] of currentMap) {
|
||||
const previous = this.previousEdges.get(id)
|
||||
if (!previous) {
|
||||
// Edge was added
|
||||
this.scheduleWrite(id, 'edge_add', null, JSON.parse(serialized))
|
||||
} else if (previous !== serialized) {
|
||||
// Edge was updated
|
||||
this.scheduleWrite(id, 'edge_update', JSON.parse(previous), JSON.parse(serialized))
|
||||
}
|
||||
}
|
||||
|
||||
// Detect deletions
|
||||
for (const [id, serialized] of this.previousEdges) {
|
||||
if (!currentMap.has(id)) {
|
||||
this.scheduleWrite(id, 'edge_delete', JSON.parse(serialized), null)
|
||||
}
|
||||
}
|
||||
|
||||
// Update previous state
|
||||
this.previousEdges = currentMap
|
||||
}
|
||||
|
||||
/** Clean up resources */
|
||||
destroy(): void {
|
||||
this.isDestroyed = true
|
||||
// Flush all pending writes immediately
|
||||
for (const [entityId, entry] of this.pending) {
|
||||
clearTimeout(entry.timer)
|
||||
this.writeEntry(entry)
|
||||
this.pending.delete(entityId)
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleWrite(
|
||||
entityId: string,
|
||||
actionType: AuditActionType,
|
||||
previousState: unknown | null,
|
||||
newState: unknown | null
|
||||
): void {
|
||||
// If there's already a pending write for this entity, cancel it and update
|
||||
const existing = this.pending.get(entityId)
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer)
|
||||
// For debounced updates, keep the original previousState but use latest newState
|
||||
// For type changes (e.g., add then delete within 1s), use the new action type
|
||||
const mergedPreviousState = existing.actionType === 'node_add' || existing.actionType === 'edge_add'
|
||||
? null // If it was just added, there's no real previous state
|
||||
: existing.previousState
|
||||
const mergedActionType = this.mergeActionTypes(existing.actionType, actionType)
|
||||
|
||||
// If an entity was added and then deleted within the debounce window, skip entirely
|
||||
if (mergedActionType === null) {
|
||||
this.pending.delete(entityId)
|
||||
return
|
||||
}
|
||||
|
||||
const entry: PendingAuditEntry = {
|
||||
actionType: mergedActionType,
|
||||
entityId,
|
||||
previousState: mergedPreviousState,
|
||||
newState,
|
||||
timer: setTimeout(() => {
|
||||
this.pending.delete(entityId)
|
||||
this.writeEntry(entry)
|
||||
}, DEBOUNCE_MS),
|
||||
}
|
||||
this.pending.set(entityId, entry)
|
||||
} else {
|
||||
const entry: PendingAuditEntry = {
|
||||
actionType,
|
||||
entityId,
|
||||
previousState,
|
||||
newState,
|
||||
timer: setTimeout(() => {
|
||||
this.pending.delete(entityId)
|
||||
this.writeEntry(entry)
|
||||
}, DEBOUNCE_MS),
|
||||
}
|
||||
this.pending.set(entityId, entry)
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge two sequential action types for the same entity */
|
||||
private mergeActionTypes(
|
||||
first: AuditActionType,
|
||||
second: AuditActionType
|
||||
): AuditActionType | null {
|
||||
// add + delete = no-op (entity never really existed)
|
||||
if (
|
||||
(first === 'node_add' && second === 'node_delete') ||
|
||||
(first === 'edge_add' && second === 'edge_delete')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
// add + update = still an add (with the updated state)
|
||||
if (
|
||||
(first === 'node_add' && second === 'node_update') ||
|
||||
(first === 'edge_add' && second === 'edge_update')
|
||||
) {
|
||||
return first
|
||||
}
|
||||
// For all other cases, use the second (latest) action type
|
||||
return second
|
||||
}
|
||||
|
||||
/** Fire-and-forget write to Supabase */
|
||||
private writeEntry(entry: PendingAuditEntry): void {
|
||||
if (this.isDestroyed) return
|
||||
|
||||
const supabase = createClient()
|
||||
supabase
|
||||
.from('audit_trail')
|
||||
.insert({
|
||||
project_id: this.projectId,
|
||||
user_id: this.userId,
|
||||
action_type: entry.actionType,
|
||||
entity_id: entry.entityId,
|
||||
previous_state: entry.previousState,
|
||||
new_state: entry.newState,
|
||||
})
|
||||
.then(({ error }) => {
|
||||
if (error) {
|
||||
console.error('[AuditTrail] Failed to write audit entry:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import * as Y from 'yjs'
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||
import type { FlowchartNode, FlowchartEdge } from '@/types/flowchart'
|
||||
|
||||
const PERSIST_DEBOUNCE_MS = 30_000 // 30s - DB persist is for crash recovery only; CRDT handles real-time sync
|
||||
const BROADCAST_EVENT = 'yjs-update'
|
||||
|
||||
export type CRDTCallbacks = {
|
||||
onNodesChange: (nodes: FlowchartNode[]) => void
|
||||
onEdgesChange: (edges: FlowchartEdge[]) => void
|
||||
onPersist: (nodes: FlowchartNode[], edges: FlowchartEdge[]) => void
|
||||
}
|
||||
|
||||
export class CRDTManager {
|
||||
private doc: Y.Doc
|
||||
private nodesMap: Y.Map<string> // node ID -> JSON string of FlowchartNode
|
||||
private edgesMap: Y.Map<string> // edge ID -> JSON string of FlowchartEdge
|
||||
private channel: RealtimeChannel | null = null
|
||||
private callbacks: CRDTCallbacks
|
||||
private persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private isApplyingRemote = false
|
||||
private isSuppressed = false // suppress broadcast/persist during init or refresh
|
||||
private isDestroyed = false
|
||||
|
||||
constructor(callbacks: CRDTCallbacks) {
|
||||
this.doc = new Y.Doc()
|
||||
this.nodesMap = this.doc.getMap('nodes')
|
||||
this.edgesMap = this.doc.getMap('edges')
|
||||
this.callbacks = callbacks
|
||||
|
||||
// Schedule persistence on local Yjs document changes
|
||||
this.nodesMap.observe(() => {
|
||||
if (this.isApplyingRemote || this.isSuppressed) return
|
||||
this.schedulePersist()
|
||||
})
|
||||
|
||||
this.edgesMap.observe(() => {
|
||||
if (this.isApplyingRemote || this.isSuppressed) return
|
||||
this.schedulePersist()
|
||||
})
|
||||
|
||||
// Broadcast local updates to other clients
|
||||
this.doc.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
if (origin === 'remote' || this.isSuppressed) return
|
||||
this.broadcastUpdate(update)
|
||||
})
|
||||
}
|
||||
|
||||
/** Initialize the Yjs document from database state */
|
||||
initializeFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
|
||||
this.isSuppressed = true
|
||||
this.doc.transact(() => {
|
||||
nodes.forEach((node) => {
|
||||
this.nodesMap.set(node.id, JSON.stringify(node))
|
||||
})
|
||||
edges.forEach((edge) => {
|
||||
this.edgesMap.set(edge.id, JSON.stringify(edge))
|
||||
})
|
||||
}, 'init')
|
||||
this.isSuppressed = false
|
||||
}
|
||||
|
||||
/** Connect to a Supabase Realtime channel for outbound broadcasts */
|
||||
connectChannel(channel: RealtimeChannel): void {
|
||||
this.channel = channel
|
||||
// Broadcast full state so other clients merge any updates they missed
|
||||
this.broadcastFullState()
|
||||
}
|
||||
|
||||
/** Broadcast the full Yjs document state to sync all connected clients */
|
||||
broadcastFullState(): void {
|
||||
if (!this.channel || this.isDestroyed) return
|
||||
const state = Y.encodeStateAsUpdate(this.doc)
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: BROADCAST_EVENT,
|
||||
payload: { update: Array.from(state) },
|
||||
})
|
||||
}
|
||||
|
||||
/** Apply a remote CRDT update received via broadcast */
|
||||
applyRemoteUpdate(updateData: number[]): void {
|
||||
if (this.isDestroyed) return
|
||||
const update = new Uint8Array(updateData)
|
||||
this.isApplyingRemote = true
|
||||
Y.applyUpdate(this.doc, update, 'remote')
|
||||
this.isApplyingRemote = false
|
||||
// Notify React state of remote changes
|
||||
this.notifyNodesChange()
|
||||
this.notifyEdgesChange()
|
||||
// Note: we do NOT schedulePersist here. Only the originating client
|
||||
// persists its own changes to avoid write races and stale data overwrites.
|
||||
}
|
||||
|
||||
/** Replace CRDT state from a database refresh without broadcasting */
|
||||
refreshFromData(nodes: FlowchartNode[], edges: FlowchartEdge[]): void {
|
||||
this.isSuppressed = true
|
||||
this.doc.transact(() => {
|
||||
// Sync nodes
|
||||
const nodeIds = new Set(nodes.map((n) => n.id))
|
||||
Array.from(this.nodesMap.keys()).forEach((id) => {
|
||||
if (!nodeIds.has(id)) this.nodesMap.delete(id)
|
||||
})
|
||||
nodes.forEach((node) => {
|
||||
const serialized = JSON.stringify(node)
|
||||
if (this.nodesMap.get(node.id) !== serialized) {
|
||||
this.nodesMap.set(node.id, serialized)
|
||||
}
|
||||
})
|
||||
|
||||
// Sync edges
|
||||
const edgeIds = new Set(edges.map((e) => e.id))
|
||||
Array.from(this.edgesMap.keys()).forEach((id) => {
|
||||
if (!edgeIds.has(id)) this.edgesMap.delete(id)
|
||||
})
|
||||
edges.forEach((edge) => {
|
||||
const serialized = JSON.stringify(edge)
|
||||
if (this.edgesMap.get(edge.id) !== serialized) {
|
||||
this.edgesMap.set(edge.id, serialized)
|
||||
}
|
||||
})
|
||||
}, 'remote')
|
||||
this.isSuppressed = false
|
||||
}
|
||||
|
||||
/** Apply local node changes to the Yjs document */
|
||||
updateNodes(nodes: FlowchartNode[]): void {
|
||||
if (this.isApplyingRemote) return
|
||||
|
||||
this.doc.transact(() => {
|
||||
const currentIds = new Set(nodes.map((n) => n.id))
|
||||
|
||||
// Remove nodes no longer present
|
||||
const existingIds = Array.from(this.nodesMap.keys())
|
||||
existingIds.forEach((id) => {
|
||||
if (!currentIds.has(id)) {
|
||||
this.nodesMap.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Add or update nodes
|
||||
nodes.forEach((node) => {
|
||||
const serialized = JSON.stringify(node)
|
||||
const existing = this.nodesMap.get(node.id)
|
||||
if (existing !== serialized) {
|
||||
this.nodesMap.set(node.id, serialized)
|
||||
}
|
||||
})
|
||||
}, 'local')
|
||||
}
|
||||
|
||||
/** Apply local edge changes to the Yjs document */
|
||||
updateEdges(edges: FlowchartEdge[]): void {
|
||||
if (this.isApplyingRemote) return
|
||||
|
||||
this.doc.transact(() => {
|
||||
const currentIds = new Set(edges.map((e) => e.id))
|
||||
|
||||
// Remove edges no longer present
|
||||
const existingIds = Array.from(this.edgesMap.keys())
|
||||
existingIds.forEach((id) => {
|
||||
if (!currentIds.has(id)) {
|
||||
this.edgesMap.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
// Add or update edges
|
||||
edges.forEach((edge) => {
|
||||
const serialized = JSON.stringify(edge)
|
||||
const existing = this.edgesMap.get(edge.id)
|
||||
if (existing !== serialized) {
|
||||
this.edgesMap.set(edge.id, serialized)
|
||||
}
|
||||
})
|
||||
}, 'local')
|
||||
}
|
||||
|
||||
/** Get current nodes from the Yjs document */
|
||||
getNodes(): FlowchartNode[] {
|
||||
const nodes: FlowchartNode[] = []
|
||||
this.nodesMap.forEach((value) => {
|
||||
try {
|
||||
nodes.push(JSON.parse(value) as FlowchartNode)
|
||||
} catch {
|
||||
// Skip malformed entries
|
||||
}
|
||||
})
|
||||
return nodes
|
||||
}
|
||||
|
||||
/** Get current edges from the Yjs document */
|
||||
getEdges(): FlowchartEdge[] {
|
||||
const edges: FlowchartEdge[] = []
|
||||
this.edgesMap.forEach((value) => {
|
||||
try {
|
||||
edges.push(JSON.parse(value) as FlowchartEdge)
|
||||
} catch {
|
||||
// Skip malformed entries
|
||||
}
|
||||
})
|
||||
return edges
|
||||
}
|
||||
|
||||
/** Clean up resources */
|
||||
destroy(): void {
|
||||
this.isDestroyed = true
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer)
|
||||
this.persistTimer = null
|
||||
}
|
||||
this.doc.destroy()
|
||||
this.channel = null
|
||||
}
|
||||
|
||||
private notifyNodesChange(): void {
|
||||
this.callbacks.onNodesChange(this.getNodes())
|
||||
}
|
||||
|
||||
private notifyEdgesChange(): void {
|
||||
this.callbacks.onEdgesChange(this.getEdges())
|
||||
}
|
||||
|
||||
private broadcastUpdate(update: Uint8Array): void {
|
||||
if (!this.channel || this.isDestroyed) return
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: BROADCAST_EVENT,
|
||||
payload: { update: Array.from(update) },
|
||||
})
|
||||
}
|
||||
|
||||
private schedulePersist(): void {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer)
|
||||
}
|
||||
this.persistTimer = setTimeout(() => {
|
||||
if (this.isDestroyed) return
|
||||
this.callbacks.onPersist(this.getNodes(), this.getEdges())
|
||||
}, PERSIST_DEBOUNCE_MS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting'
|
||||
|
||||
export type PresenceUser = {
|
||||
userId: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export type CursorPosition = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type RemoteCursor = {
|
||||
userId: string
|
||||
displayName: string
|
||||
position: CursorPosition
|
||||
lastUpdated: number
|
||||
}
|
||||
|
||||
export type NodeLock = {
|
||||
nodeId: string
|
||||
userId: string
|
||||
displayName: string
|
||||
lockedAt: number
|
||||
}
|
||||
|
||||
type RealtimeCallbacks = {
|
||||
onConnectionStateChange: (state: ConnectionState) => void
|
||||
onPresenceSync?: (users: PresenceUser[]) => void
|
||||
onPresenceJoin?: (user: PresenceUser) => void
|
||||
onPresenceLeave?: (user: PresenceUser) => void
|
||||
onChannelSubscribed?: (channel: RealtimeChannel) => void
|
||||
onCursorUpdate?: (cursor: RemoteCursor) => void
|
||||
onNodeLockUpdate?: (lock: NodeLock | null, userId: string) => void
|
||||
onCRDTUpdate?: (update: number[]) => void
|
||||
onStateRefresh?: () => void
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000
|
||||
const RECONNECT_BASE_DELAY_MS = 1000
|
||||
const RECONNECT_MAX_DELAY_MS = 30_000
|
||||
const INACTIVITY_TIMEOUT_MS = 5 * 60_000 // 5 minutes of inactivity before pausing
|
||||
const CONNECTION_TIMEOUT_MS = 15_000 // 15s timeout for initial connection
|
||||
const STALE_THRESHOLD_MS = 3 * 60_000 // 3 minutes hidden before forcing fresh reconnect
|
||||
|
||||
export class RealtimeConnection {
|
||||
private channel: RealtimeChannel | null = null
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private inactivityTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private connectionTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private reconnectAttempts = 0
|
||||
private projectId: string
|
||||
private userId: string
|
||||
private displayName: string
|
||||
private callbacks: RealtimeCallbacks
|
||||
private isDestroyed = false
|
||||
private isPaused = false
|
||||
private lastActivityTime = Date.now()
|
||||
private supabase = createClient()
|
||||
|
||||
constructor(projectId: string, userId: string, displayName: string, callbacks: RealtimeCallbacks) {
|
||||
this.projectId = projectId
|
||||
this.userId = userId
|
||||
this.displayName = displayName
|
||||
this.callbacks = callbacks
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.isDestroyed) return
|
||||
this.isPaused = false
|
||||
this.lastActivityTime = Date.now()
|
||||
this.callbacks.onConnectionStateChange('connecting')
|
||||
this.resetInactivityTimer()
|
||||
this.clearConnectionTimer()
|
||||
|
||||
// Ensure the Supabase client has a valid auth session before connecting.
|
||||
// On initial page load, the session may still be loading from cookies.
|
||||
try {
|
||||
const { data: { session } } = await this.supabase.auth.getSession()
|
||||
if (!session && !this.isDestroyed) {
|
||||
// No session yet - wait briefly and retry
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Session check failed - proceed anyway, channel will handle auth errors
|
||||
}
|
||||
|
||||
if (this.isDestroyed || this.isPaused) return
|
||||
|
||||
// Set a timeout: if we don't connect within CONNECTION_TIMEOUT_MS, retry
|
||||
this.connectionTimer = setTimeout(() => {
|
||||
if (this.isDestroyed || this.isPaused) return
|
||||
if (this.channel) {
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
}
|
||||
this.scheduleReconnect()
|
||||
}, CONNECTION_TIMEOUT_MS)
|
||||
|
||||
this.channel = this.supabase.channel(`project:${this.projectId}`, {
|
||||
config: { presence: { key: this.userId } },
|
||||
})
|
||||
|
||||
this.channel
|
||||
.on('presence', { event: 'sync' }, () => {
|
||||
if (this.channel) {
|
||||
const state = this.channel.presenceState()
|
||||
const users: PresenceUser[] = []
|
||||
for (const [key, presences] of Object.entries(state)) {
|
||||
if (key === this.userId) continue // Exclude own presence
|
||||
const presence = presences[0] as { userId?: string; displayName?: string } | undefined
|
||||
if (presence?.userId) {
|
||||
users.push({
|
||||
userId: presence.userId,
|
||||
displayName: presence.displayName || 'Anonymous',
|
||||
})
|
||||
}
|
||||
}
|
||||
this.callbacks.onPresenceSync?.(users)
|
||||
}
|
||||
})
|
||||
.on('presence', { event: 'join' }, ({ newPresences }) => {
|
||||
for (const presence of newPresences) {
|
||||
const p = presence as { userId?: string; displayName?: string }
|
||||
if (p.userId && p.userId !== this.userId) {
|
||||
this.callbacks.onPresenceJoin?.({
|
||||
userId: p.userId,
|
||||
displayName: p.displayName || 'Anonymous',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
|
||||
for (const presence of leftPresences) {
|
||||
const p = presence as { userId?: string; displayName?: string }
|
||||
if (p.userId && p.userId !== this.userId) {
|
||||
this.callbacks.onPresenceLeave?.({
|
||||
userId: p.userId,
|
||||
displayName: p.displayName || 'Anonymous',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
|
||||
if (payload.userId === this.userId) return
|
||||
this.callbacks.onCursorUpdate?.({
|
||||
userId: payload.userId,
|
||||
displayName: payload.displayName,
|
||||
position: { x: payload.x, y: payload.y },
|
||||
lastUpdated: Date.now(),
|
||||
})
|
||||
})
|
||||
.on('broadcast', { event: 'node-lock' }, ({ payload }) => {
|
||||
if (payload.userId === this.userId) return
|
||||
if (payload.nodeId) {
|
||||
this.callbacks.onNodeLockUpdate?.({
|
||||
nodeId: payload.nodeId,
|
||||
userId: payload.userId,
|
||||
displayName: payload.displayName,
|
||||
lockedAt: payload.lockedAt,
|
||||
}, payload.userId)
|
||||
} else {
|
||||
// null nodeId means unlock
|
||||
this.callbacks.onNodeLockUpdate?.(null, payload.userId)
|
||||
}
|
||||
})
|
||||
.on('broadcast', { event: 'yjs-update' }, ({ payload }) => {
|
||||
if (payload?.update) {
|
||||
this.callbacks.onCRDTUpdate?.(payload.update)
|
||||
}
|
||||
})
|
||||
.on('broadcast', { event: 'state-refresh' }, () => {
|
||||
this.callbacks.onStateRefresh?.()
|
||||
})
|
||||
.subscribe(async (status) => {
|
||||
if (this.isDestroyed) return
|
||||
this.clearConnectionTimer()
|
||||
|
||||
if (status === 'SUBSCRIBED') {
|
||||
this.reconnectAttempts = 0
|
||||
this.callbacks.onConnectionStateChange('connected')
|
||||
this.startHeartbeat()
|
||||
await this.createSession()
|
||||
// Track presence with user info
|
||||
await this.channel?.track({
|
||||
userId: this.userId,
|
||||
displayName: this.displayName,
|
||||
})
|
||||
// Notify that the channel is ready for CRDT sync
|
||||
if (this.channel) {
|
||||
this.callbacks.onChannelSubscribed?.(this.channel)
|
||||
}
|
||||
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
||||
this.callbacks.onConnectionStateChange('reconnecting')
|
||||
this.scheduleReconnect()
|
||||
} else if (status === 'CLOSED') {
|
||||
if (!this.isPaused) {
|
||||
// Unexpected close - attempt to reconnect
|
||||
this.callbacks.onConnectionStateChange('reconnecting')
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
this.callbacks.onConnectionStateChange('disconnected')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.isDestroyed = true
|
||||
this.isPaused = false
|
||||
this.stopHeartbeat()
|
||||
this.clearReconnectTimer()
|
||||
this.clearInactivityTimer()
|
||||
this.clearConnectionTimer()
|
||||
|
||||
if (this.channel) {
|
||||
await this.deleteSession()
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
}
|
||||
|
||||
this.callbacks.onConnectionStateChange('disconnected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the connection (e.g. on inactivity or tab hidden).
|
||||
* Unlike disconnect(), this allows resuming later.
|
||||
*/
|
||||
async pause(): Promise<void> {
|
||||
if (this.isDestroyed || this.isPaused) return
|
||||
this.isPaused = true
|
||||
this.stopHeartbeat()
|
||||
this.clearReconnectTimer()
|
||||
this.clearInactivityTimer()
|
||||
this.clearConnectionTimer()
|
||||
|
||||
if (this.channel) {
|
||||
await this.deleteSession()
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
}
|
||||
|
||||
this.callbacks.onConnectionStateChange('disconnected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the connection after it was paused.
|
||||
* Re-establishes the channel and presence.
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
if (this.isDestroyed || !this.isPaused) return
|
||||
this.isPaused = false
|
||||
this.reconnectAttempts = 0
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify that user activity has occurred, resetting the inactivity timer.
|
||||
* If the connection was paused due to inactivity, it will resume.
|
||||
*/
|
||||
notifyActivity(): void {
|
||||
if (this.isDestroyed) return
|
||||
this.lastActivityTime = Date.now()
|
||||
this.resetInactivityTimer()
|
||||
|
||||
if (this.isPaused) {
|
||||
this.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the tab becomes visible again. If the tab was hidden for a
|
||||
* significant period, force a fresh reconnect to handle stale WebSockets.
|
||||
*/
|
||||
notifyVisibilityResumed(): void {
|
||||
if (this.isDestroyed) return
|
||||
const now = Date.now()
|
||||
const hiddenDuration = now - this.lastActivityTime
|
||||
this.lastActivityTime = now
|
||||
this.resetInactivityTimer()
|
||||
|
||||
if (this.isPaused) {
|
||||
this.resume()
|
||||
} else if (hiddenDuration > STALE_THRESHOLD_MS && this.channel) {
|
||||
this.forceReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a fresh reconnect by tearing down the current channel and reconnecting.
|
||||
*/
|
||||
private async forceReconnect(): Promise<void> {
|
||||
if (this.isDestroyed || this.isPaused) return
|
||||
this.stopHeartbeat()
|
||||
this.clearReconnectTimer()
|
||||
this.clearConnectionTimer()
|
||||
|
||||
if (this.channel) {
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
}
|
||||
|
||||
this.reconnectAttempts = 0
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
getIsPaused(): boolean {
|
||||
return this.isPaused
|
||||
}
|
||||
|
||||
getChannel(): RealtimeChannel | null {
|
||||
return this.channel
|
||||
}
|
||||
|
||||
broadcastCursor(position: CursorPosition): void {
|
||||
if (!this.channel) return
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'cursor',
|
||||
payload: {
|
||||
userId: this.userId,
|
||||
displayName: this.displayName,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
broadcastStateRefresh(): void {
|
||||
if (!this.channel) return
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'state-refresh',
|
||||
payload: {},
|
||||
})
|
||||
}
|
||||
|
||||
broadcastNodeLock(nodeId: string | null): void {
|
||||
if (!this.channel) return
|
||||
this.channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'node-lock',
|
||||
payload: {
|
||||
userId: this.userId,
|
||||
displayName: this.displayName,
|
||||
nodeId,
|
||||
lockedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async createSession(): Promise<void> {
|
||||
try {
|
||||
await this.supabase.from('collaboration_sessions').upsert(
|
||||
{
|
||||
project_id: this.projectId,
|
||||
user_id: this.userId,
|
||||
cursor_position: null,
|
||||
selected_node_id: null,
|
||||
connected_at: new Date().toISOString(),
|
||||
last_heartbeat: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'project_id,user_id' }
|
||||
)
|
||||
} catch {
|
||||
// Fire-and-forget: session creation failure shouldn't block editing
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteSession(): Promise<void> {
|
||||
try {
|
||||
await this.supabase
|
||||
.from('collaboration_sessions')
|
||||
.delete()
|
||||
.eq('project_id', this.projectId)
|
||||
.eq('user_id', this.userId)
|
||||
} catch {
|
||||
// Fire-and-forget: cleanup failure is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
let consecutiveFailures = 0
|
||||
this.heartbeatTimer = setInterval(async () => {
|
||||
if (this.isDestroyed || this.isPaused) {
|
||||
this.stopHeartbeat()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the channel is still in a healthy state
|
||||
if (this.channel) {
|
||||
const state = (this.channel as unknown as { state?: string }).state
|
||||
if (state && state !== 'joined' && state !== 'joining') {
|
||||
// Channel is in an unhealthy state - trigger reconnect
|
||||
this.callbacks.onConnectionStateChange('reconnecting')
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
this.stopHeartbeat()
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.supabase
|
||||
.from('collaboration_sessions')
|
||||
.update({ last_heartbeat: new Date().toISOString() })
|
||||
.eq('project_id', this.projectId)
|
||||
.eq('user_id', this.userId)
|
||||
consecutiveFailures = 0
|
||||
} catch {
|
||||
consecutiveFailures++
|
||||
// If heartbeat fails 3 times in a row, the connection is likely dead
|
||||
if (consecutiveFailures >= 3 && this.channel) {
|
||||
this.callbacks.onConnectionStateChange('reconnecting')
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
this.stopHeartbeat()
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.isDestroyed) return
|
||||
this.clearReconnectTimer()
|
||||
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts),
|
||||
RECONNECT_MAX_DELAY_MS
|
||||
)
|
||||
this.reconnectAttempts++
|
||||
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
if (this.isDestroyed) return
|
||||
this.callbacks.onConnectionStateChange('reconnecting')
|
||||
|
||||
if (this.channel) {
|
||||
this.supabase.removeChannel(this.channel)
|
||||
this.channel = null
|
||||
}
|
||||
|
||||
await this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private resetInactivityTimer(): void {
|
||||
this.clearInactivityTimer()
|
||||
if (this.isDestroyed) return
|
||||
this.inactivityTimer = setTimeout(() => {
|
||||
if (!this.isDestroyed && !this.isPaused) {
|
||||
this.pause()
|
||||
}
|
||||
}, INACTIVITY_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private clearInactivityTimer(): void {
|
||||
if (this.inactivityTimer) {
|
||||
clearTimeout(this.inactivityTimer)
|
||||
this.inactivityTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private clearConnectionTimer(): void {
|
||||
if (this.connectionTimer) {
|
||||
clearTimeout(this.connectionTimer)
|
||||
this.connectionTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,14 @@ export type Variable = {
|
||||
description?: string;
|
||||
};
|
||||
|
||||
// Condition type for conditional edges and choice options
|
||||
export type Condition = {
|
||||
variableName: string;
|
||||
variableId?: string;
|
||||
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
||||
value: number | string | boolean;
|
||||
};
|
||||
|
||||
// DialogueNode type: represents character speech/dialogue
|
||||
export type DialogueNodeData = {
|
||||
speaker?: string;
|
||||
@@ -39,6 +47,7 @@ export type DialogueNode = {
|
||||
export type ChoiceOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
condition?: Condition;
|
||||
};
|
||||
|
||||
// ChoiceNode type: represents branching decisions
|
||||
@@ -70,14 +79,6 @@ export type VariableNode = {
|
||||
// Union type for all node types
|
||||
export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode;
|
||||
|
||||
// Condition type for conditional edges
|
||||
export type Condition = {
|
||||
variableName: string;
|
||||
variableId?: string;
|
||||
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
||||
value: number;
|
||||
};
|
||||
|
||||
// FlowchartEdge type: represents connections between nodes
|
||||
export type FlowchartEdge = {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
-- Migration: Add collaboration sessions and audit trail tables
|
||||
-- Supports real-time multi-user editing with presence tracking and change history
|
||||
|
||||
-- =============================================================================
|
||||
-- PROJECT COLLABORATORS TABLE
|
||||
-- =============================================================================
|
||||
-- Tracks which users have access to a project and their role
|
||||
CREATE TABLE IF NOT EXISTS project_collaborators (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
role text NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
|
||||
invited_at timestamptz DEFAULT now(),
|
||||
accepted_at timestamptz,
|
||||
UNIQUE (project_id, user_id)
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE project_collaborators ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS: Users can view collaborators for projects they belong to
|
||||
CREATE POLICY "Users can view collaborators for their projects"
|
||||
ON project_collaborators
|
||||
FOR SELECT
|
||||
USING (
|
||||
auth.uid() = user_id
|
||||
OR project_id IN (
|
||||
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
|
||||
)
|
||||
OR project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS: Project owners can insert collaborators
|
||||
CREATE POLICY "Owners can add collaborators"
|
||||
ON project_collaborators
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS: Project owners can update collaborator roles
|
||||
CREATE POLICY "Owners can update collaborators"
|
||||
ON project_collaborators
|
||||
FOR UPDATE
|
||||
USING (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS: Project owners can remove collaborators
|
||||
CREATE POLICY "Owners can remove collaborators"
|
||||
ON project_collaborators
|
||||
FOR DELETE
|
||||
USING (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- COLLABORATION SESSIONS TABLE
|
||||
-- =============================================================================
|
||||
-- Tracks active editing sessions for real-time presence
|
||||
CREATE TABLE IF NOT EXISTS collaboration_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
cursor_position jsonb,
|
||||
selected_node_id text,
|
||||
connected_at timestamptz DEFAULT now(),
|
||||
last_heartbeat timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE collaboration_sessions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS: Users can view sessions for projects they collaborate on
|
||||
CREATE POLICY "Collaborators can view sessions"
|
||||
ON collaboration_sessions
|
||||
FOR SELECT
|
||||
USING (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
OR project_id IN (
|
||||
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS: Users can insert their own sessions
|
||||
CREATE POLICY "Users can create own sessions"
|
||||
ON collaboration_sessions
|
||||
FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- RLS: Users can update their own sessions (heartbeat, cursor position)
|
||||
CREATE POLICY "Users can update own sessions"
|
||||
ON collaboration_sessions
|
||||
FOR UPDATE
|
||||
USING (auth.uid() = user_id)
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- RLS: Users can delete their own sessions (disconnect)
|
||||
CREATE POLICY "Users can delete own sessions"
|
||||
ON collaboration_sessions
|
||||
FOR DELETE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- AUDIT TRAIL TABLE
|
||||
-- =============================================================================
|
||||
-- Records all node and edge changes for history and revert functionality
|
||||
CREATE TABLE IF NOT EXISTS audit_trail (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
action_type text NOT NULL CHECK (action_type IN ('node_add', 'node_update', 'node_delete', 'edge_add', 'edge_update', 'edge_delete')),
|
||||
entity_id text NOT NULL,
|
||||
previous_state jsonb,
|
||||
new_state jsonb,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE audit_trail ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS: Users can view audit trail for projects they collaborate on
|
||||
CREATE POLICY "Collaborators can view audit trail"
|
||||
ON audit_trail
|
||||
FOR SELECT
|
||||
USING (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
OR project_id IN (
|
||||
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS: Users can insert audit entries for projects they collaborate on
|
||||
CREATE POLICY "Collaborators can write audit entries"
|
||||
ON audit_trail
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.uid() = user_id
|
||||
AND (
|
||||
project_id IN (
|
||||
SELECT id FROM projects WHERE user_id = auth.uid()
|
||||
)
|
||||
OR project_id IN (
|
||||
SELECT project_id FROM project_collaborators
|
||||
WHERE user_id = auth.uid() AND role IN ('owner', 'editor')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- INDEXES
|
||||
-- =============================================================================
|
||||
-- Index for efficient history queries (paginated by time)
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_trail_project_created
|
||||
ON audit_trail(project_id, created_at DESC);
|
||||
|
||||
-- Index for session lookups by project
|
||||
CREATE INDEX IF NOT EXISTS idx_collaboration_sessions_project
|
||||
ON collaboration_sessions(project_id);
|
||||
|
||||
-- Index for collaborator lookups by project
|
||||
CREATE INDEX IF NOT EXISTS idx_project_collaborators_project
|
||||
ON project_collaborators(project_id);
|
||||
|
||||
-- Index for collaborator lookups by user (for "shared with me" queries)
|
||||
CREATE INDEX IF NOT EXISTS idx_project_collaborators_user
|
||||
ON project_collaborators(user_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- UPDATE PROJECTS RLS: Allow collaborators to read/write
|
||||
-- =============================================================================
|
||||
-- Collaborators with 'editor' or 'owner' role can view the project
|
||||
CREATE POLICY "Collaborators can view shared projects"
|
||||
ON projects
|
||||
FOR SELECT
|
||||
USING (
|
||||
id IN (
|
||||
SELECT project_id FROM project_collaborators WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Collaborators with 'editor' or 'owner' role can update the project
|
||||
CREATE POLICY "Collaborators can update shared projects"
|
||||
ON projects
|
||||
FOR UPDATE
|
||||
USING (
|
||||
id IN (
|
||||
SELECT project_id FROM project_collaborators
|
||||
WHERE user_id = auth.uid() AND role IN ('owner', 'editor')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
id IN (
|
||||
SELECT project_id FROM project_collaborators
|
||||
WHERE user_id = auth.uid() AND role IN ('owner', 'editor')
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add unique constraint on collaboration_sessions for (project_id, user_id)
|
||||
-- This ensures a user can only have one active session per project,
|
||||
-- and allows upsert operations for session management.
|
||||
ALTER TABLE collaboration_sessions
|
||||
ADD CONSTRAINT collaboration_sessions_project_user_unique
|
||||
UNIQUE (project_id, user_id);
|
||||
@@ -0,0 +1,174 @@
|
||||
-- Migration: Fix infinite recursion in RLS policies
|
||||
-- Problem: Self-referencing policies and circular dependencies between
|
||||
-- projects <-> project_collaborators cause infinite recursion.
|
||||
-- Solution: Use SECURITY DEFINER functions to bypass RLS for permission checks.
|
||||
|
||||
-- =============================================================================
|
||||
-- HELPER FUNCTIONS (SECURITY DEFINER bypasses RLS)
|
||||
-- =============================================================================
|
||||
|
||||
-- Check if the current user is an admin
|
||||
CREATE OR REPLACE FUNCTION is_admin()
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE id = auth.uid()
|
||||
AND is_admin = true
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Check if the current user is the owner of a project
|
||||
CREATE OR REPLACE FUNCTION is_project_owner(p_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM projects
|
||||
WHERE id = p_project_id
|
||||
AND user_id = auth.uid()
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Check if the current user is a collaborator on a project (any role)
|
||||
CREATE OR REPLACE FUNCTION is_project_collaborator(p_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM project_collaborators
|
||||
WHERE project_id = p_project_id
|
||||
AND user_id = auth.uid()
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Check if the current user is an editor or owner collaborator on a project
|
||||
CREATE OR REPLACE FUNCTION is_project_editor(p_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM project_collaborators
|
||||
WHERE project_id = p_project_id
|
||||
AND user_id = auth.uid()
|
||||
AND role IN ('owner', 'editor')
|
||||
);
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- FIX PROFILES POLICIES
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop the problematic admin policy (self-references profiles table)
|
||||
DROP POLICY IF EXISTS "Admins can view all profiles" ON profiles;
|
||||
|
||||
-- Recreate using the helper function
|
||||
CREATE POLICY "Admins can view all profiles"
|
||||
ON profiles
|
||||
FOR SELECT
|
||||
USING (is_admin());
|
||||
|
||||
-- =============================================================================
|
||||
-- FIX PROJECT_COLLABORATORS POLICIES
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop the problematic SELECT policy (self-references project_collaborators)
|
||||
DROP POLICY IF EXISTS "Users can view collaborators for their projects" ON project_collaborators;
|
||||
|
||||
-- Recreate without self-reference: user is either the collaborator row's user,
|
||||
-- the project owner, or already a collaborator on that project
|
||||
CREATE POLICY "Users can view collaborators for their projects"
|
||||
ON project_collaborators
|
||||
FOR SELECT
|
||||
USING (
|
||||
auth.uid() = user_id
|
||||
OR is_project_owner(project_id)
|
||||
OR is_project_collaborator(project_id)
|
||||
);
|
||||
|
||||
-- Drop and recreate INSERT/UPDATE/DELETE policies to use helper functions
|
||||
DROP POLICY IF EXISTS "Owners can add collaborators" ON project_collaborators;
|
||||
CREATE POLICY "Owners can add collaborators"
|
||||
ON project_collaborators
|
||||
FOR INSERT
|
||||
WITH CHECK (is_project_owner(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS "Owners can update collaborators" ON project_collaborators;
|
||||
CREATE POLICY "Owners can update collaborators"
|
||||
ON project_collaborators
|
||||
FOR UPDATE
|
||||
USING (is_project_owner(project_id))
|
||||
WITH CHECK (is_project_owner(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS "Owners can remove collaborators" ON project_collaborators;
|
||||
CREATE POLICY "Owners can remove collaborators"
|
||||
ON project_collaborators
|
||||
FOR DELETE
|
||||
USING (is_project_owner(project_id));
|
||||
|
||||
-- =============================================================================
|
||||
-- FIX COLLABORATION_SESSIONS POLICIES
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop the SELECT policy that cross-references both projects and project_collaborators
|
||||
DROP POLICY IF EXISTS "Collaborators can view sessions" ON collaboration_sessions;
|
||||
|
||||
CREATE POLICY "Collaborators can view sessions"
|
||||
ON collaboration_sessions
|
||||
FOR SELECT
|
||||
USING (
|
||||
is_project_owner(project_id)
|
||||
OR is_project_collaborator(project_id)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- FIX AUDIT_TRAIL POLICIES
|
||||
-- =============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS "Collaborators can view audit trail" ON audit_trail;
|
||||
CREATE POLICY "Collaborators can view audit trail"
|
||||
ON audit_trail
|
||||
FOR SELECT
|
||||
USING (
|
||||
is_project_owner(project_id)
|
||||
OR is_project_collaborator(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Collaborators can write audit entries" ON audit_trail;
|
||||
CREATE POLICY "Collaborators can write audit entries"
|
||||
ON audit_trail
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.uid() = user_id
|
||||
AND (
|
||||
is_project_owner(project_id)
|
||||
OR is_project_editor(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- FIX PROJECTS POLICIES (collaborator access)
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop the policies that query project_collaborators (creating circular deps)
|
||||
DROP POLICY IF EXISTS "Collaborators can view shared projects" ON projects;
|
||||
CREATE POLICY "Collaborators can view shared projects"
|
||||
ON projects
|
||||
FOR SELECT
|
||||
USING (is_project_collaborator(id));
|
||||
|
||||
DROP POLICY IF EXISTS "Collaborators can update shared projects" ON projects;
|
||||
CREATE POLICY "Collaborators can update shared projects"
|
||||
ON projects
|
||||
FOR UPDATE
|
||||
USING (is_project_editor(id))
|
||||
WITH CHECK (is_project_editor(id));
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration: Allow authenticated users to look up profiles for sharing
|
||||
-- Problem: The profiles RLS policies only allow users to see their own profile
|
||||
-- (or admins to see all). This prevents the sharing feature from finding
|
||||
-- users by email since the query is blocked by RLS.
|
||||
-- Solution: Add a SELECT policy allowing any authenticated user to view profiles.
|
||||
|
||||
CREATE POLICY "Authenticated users can view profiles"
|
||||
ON profiles
|
||||
FOR SELECT
|
||||
USING (auth.uid() IS NOT NULL);
|
||||
Reference in New Issue
Block a user