developing #10
248
progress.txt
248
progress.txt
|
|
@ -201,102 +201,198 @@
|
||||||
- No browser testing tools are available; manual verification is needed.
|
- No browser testing tools are available; manual verification is needed.
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-064
|
## 2026-01-22 - US-030
|
||||||
- What was implemented: Export validation that scans nodes/edges for undefined character/variable references before exporting, with a warning modal and canvas highlighting
|
- What was implemented: Right-click context menu for canvas, nodes, and edges
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `src/components/editor/ExportValidationModal.tsx` - New component: warning modal listing validation issues by type (character/variable), showing node type badge and content snippet, with "Export anyway" and "Cancel" buttons
|
- src/components/editor/ContextMenu.tsx - new component with menu items for different contexts (canvas/node/edge)
|
||||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Updated `handleExport` to scan nodes (dialogue, variable, choice) and edges for characterId/variableId references not matching defined entries; added `performExport`, `handleExportAnyway`, `handleExportCancel` callbacks; added `validationIssues` and `warningNodeIds` state; applied `export-warning-node` className to affected nodes via `styledNodes` memo
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated context menu with handlers for all actions
|
||||||
- `src/app/globals.css` - Added `.react-flow__node.export-warning-node` CSS with orange outline and pulse animation for highlighted nodes
|
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- React Flow nodes support a `className` prop that gets applied to the `.react-flow__node` wrapper div, so custom styling can be applied via CSS without modifying node component internals.
|
- Use `onPaneContextMenu`, `onNodeContextMenu`, and `onEdgeContextMenu` React Flow callbacks for context menus
|
||||||
- The validation scans four sources of references: dialogue nodes (characterId), variable nodes (variableId), choice node option conditions (variableId), and edge conditions (variableId).
|
- `screenToFlowPosition()` converts screen coordinates to flow coordinates for placing nodes at click position
|
||||||
- The `handleExport` is the validation gate; `performExport` is where actual export logic (US-035) will go. When no issues are found, `performExport` is called directly. Otherwise the modal is shown and the user decides.
|
- Context menu state includes type ('canvas'|'node'|'edge') and optional nodeId/edgeId for targeted actions
|
||||||
- Warning highlighting is cleared either by canceling the modal or by proceeding with "Export anyway".
|
- Use `document.addEventListener('click', handler)` and `e.stopPropagation()` on menu to close on outside click
|
||||||
- Pre-existing lint issues in `ConditionEditor.tsx` and `OptionConditionEditor.tsx` (React Compiler `preserve-manual-memoization` errors) are from prior stories and not related to this change.
|
- Escape key listener via `document.addEventListener('keydown', handler)` for menu close
|
||||||
- No browser testing tools are available; manual verification is needed.
|
- NodeMouseHandler and EdgeMouseHandler types from reactflow provide proper typing for context menu callbacks
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-043
|
## 2026-01-22 - US-031
|
||||||
- What was implemented: Database migration adding project_collaborators, collaboration_sessions, and audit_trail tables with RLS policies and indexes
|
- What was implemented: Condition editor modal for adding/editing/removing conditions on edges
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `supabase/migrations/20260123100000_add_collaboration_and_audit_trail.sql` - New migration with three tables, RLS policies, indexes, and updated projects RLS for collaborator access
|
- 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:**
|
- **Learnings for future iterations:**
|
||||||
- `project_collaborators` has a UNIQUE constraint on (project_id, user_id) to prevent duplicate invitations
|
- Use `onEdgeDoubleClick` React Flow callback for double-click on edges
|
||||||
- RLS policies for collaboration tables use subqueries to check either project ownership (via `projects.user_id`) or collaboration membership (via `project_collaborators.user_id`)
|
- Store condition editor state separately from context menu state (`conditionEditor` vs `contextMenu`)
|
||||||
- The audit_trail insert policy requires both `auth.uid() = user_id` AND project access (owner or editor role) to prevent unauthorized audit writes
|
- Use `edge.data.condition` to access condition object on edges
|
||||||
- New RLS policies were added to the existing `projects` table to allow collaborators to SELECT and UPDATE (editors/owners only) shared projects
|
- When removing properties from edge data, use `delete` operator instead of destructuring to avoid lint warnings about unused variables
|
||||||
- The audit_trail index uses `created_at DESC` for efficient reverse-chronological pagination in the history sidebar
|
- Condition type has operators: '>' | '<' | '==' | '>=' | '<=' | '!='
|
||||||
- `collaboration_sessions.cursor_position` is JSONB to store flexible coordinate data (x, y, and potentially viewport info)
|
- Preview condition in modal using template string: `${variableName} ${operator} ${value}`
|
||||||
- `collaboration_sessions.selected_node_id` is nullable text since a user may not have any node selected
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-045
|
## 2026-01-22 - US-032
|
||||||
- What was implemented: Supabase Realtime channel and connection management with connection lifecycle, heartbeat, auto-reconnect, and toolbar status indicator
|
- What was implemented: Display conditions on edges with dashed styling and labels
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `src/lib/collaboration/realtime.ts` - New module: `RealtimeConnection` class with connect/disconnect, heartbeat (30s interval), exponential backoff reconnect, session upsert/delete, presence sync callback
|
- src/components/editor/edges/ConditionalEdge.tsx - new custom edge component with condition display
|
||||||
- `src/components/editor/Toolbar.tsx` - Added `connectionState` optional prop with color-coded indicator (green=connected, yellow=connecting/reconnecting, red=disconnected)
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated custom edge type, added EdgeTypes import and edgeTypes definition
|
||||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `userId` prop, imported `RealtimeConnection`, added `connectionState` state + `useEffect` for connection lifecycle, passed `connectionState` to Toolbar
|
|
||||||
- `src/app/editor/[projectId]/page.tsx` - Passed `userId={user.id}` to FlowchartEditor component
|
|
||||||
- `supabase/migrations/20260123200000_add_collaboration_sessions_unique_constraint.sql` - Added UNIQUE(project_id, user_id) constraint for upsert support
|
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- Supabase Realtime channel subscription statuses are: `SUBSCRIBED`, `CHANNEL_ERROR`, `TIMED_OUT`, `CLOSED`. Handle each for connection state tracking.
|
- Custom React Flow edges use EdgeProps<T> typing where T is the data shape
|
||||||
- The `RealtimeConnection` class creates its own Supabase client instance via `createClient()` since the browser client is stateless and cheap to create
|
- Use `BaseEdge` component for rendering the edge path, and `EdgeLabelRenderer` for positioning labels
|
||||||
- `useRef` is used to hold the connection instance across renders without triggering re-renders. The `useEffect` cleanup calls `disconnect()` to properly clean up.
|
- `getSmoothStepPath` returns [edgePath, labelX, labelY] - labelX/labelY are center coordinates for labels
|
||||||
- The Supabase channel name is `project:{projectId}` — future stories (presence, CRDT) should join the same channel via `realtimeRef.current.getChannel()`
|
- Custom edge types are registered in edgeTypes object (similar to nodeTypes) and passed to ReactFlow
|
||||||
- The `collaboration_sessions` table needed a UNIQUE constraint on (project_id, user_id) for the upsert pattern to work; this was added as a separate migration
|
- Style edges with conditions using strokeDasharray: '5 5' for dashed lines
|
||||||
- Pre-existing lint errors in ConditionEditor.tsx and OptionConditionEditor.tsx (React Compiler `preserve-manual-memoization`) are unrelated to this story
|
- Custom edges go in `src/components/editor/edges/` directory
|
||||||
- No browser testing tools are available; manual verification is needed.
|
- Use amber color scheme for conditional edges to distinguish from regular edges
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-044
|
## 2026-01-22 - US-033
|
||||||
- What was implemented: Project sharing and collaborator management with Share button, modal, server actions, collaborator access to editor, and dashboard shared projects section
|
- What was implemented: Auto-save to LocalStorage with debounced saves and draft restoration prompt
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `src/app/editor/[projectId]/actions.ts` - New server actions module: `getCollaborators`, `inviteCollaborator`, `updateCollaboratorRole`, `removeCollaborator` with ownership verification and profile lookups
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - added LocalStorage auto-save functionality, draft check on load, and restoration prompt UI
|
||||||
- `src/components/editor/ShareModal.tsx` - New modal component: invite form (email + role), collaborator list with role change and remove actions, error/success messaging
|
|
||||||
- `src/components/editor/Toolbar.tsx` - Added `onShare` prop and "Share" button between "Project Settings" and "Save"
|
|
||||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `isOwner` prop, `showShare` state, ShareModal rendering, passed `onShare` to Toolbar
|
|
||||||
- `src/app/editor/[projectId]/page.tsx` - Updated project fetching to support collaborator access (owner-first lookup, fallback to project_collaborators check), pass `isOwner` prop
|
|
||||||
- `src/app/dashboard/page.tsx` - Added query for shared projects via `project_collaborators` join, rendered "Shared with me" section with ProjectCard
|
|
||||||
- `src/components/ProjectCard.tsx` - Made `onDelete`/`onRename` optional, added `shared`/`sharedRole` props, conditionally shows role badge instead of edit/delete actions for shared projects
|
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- Supabase join queries with `profiles(display_name, email)` return the type as an array due to generated types. Cast through `unknown` to the expected shape: `c.profiles as unknown as { display_name: string | null; email: string | null } | null`
|
- Use lazy useState initializer for draft check to avoid ESLint "setState in effect" warning
|
||||||
- The RLS policies from US-043 migration already handle collaborator access to projects (SELECT and UPDATE for editors). No new migration was needed for this story.
|
- LocalStorage key format: `vnwrite-draft-{projectId}` for project-specific drafts
|
||||||
- For editor page.tsx, checking collaborator access requires two queries: first try `.eq('user_id', user.id)` for ownership, then check `project_collaborators` table. RLS on projects allows collaborators to SELECT, so the second `.from('projects')` query works.
|
- Debounce saves with 1 second delay using useRef for timer tracking
|
||||||
- Server actions in App Router can be co-located with page.tsx in the same route directory (e.g., `src/app/editor/[projectId]/actions.ts`)
|
- Convert React Flow Node/Edge types back to app types using helper functions (fromReactFlowNodes, fromReactFlowEdges)
|
||||||
- The `accepted_at` field is auto-set on invite (auto-accept pattern). A pending invitation flow would require leaving `accepted_at` null and adding an accept action.
|
- React Flow Edge has `sourceHandle: string | null | undefined` but app types use `string | undefined` - use nullish coalescing (`?? undefined`)
|
||||||
- No browser testing tools are available; manual verification is needed.
|
- 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-23 - US-046
|
## 2026-01-22 - US-034
|
||||||
- What was implemented: Presence indicators showing connected collaborators as avatar circles in the editor toolbar
|
- What was implemented: Save project to database functionality
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `src/lib/collaboration/realtime.ts` - Added `PresenceUser` type, `displayName` constructor param, presence tracking via `channel.track()`, presence sync parsing that excludes own user
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleSave with Supabase update, added isSaving state and Toast notifications
|
||||||
- `src/components/editor/PresenceAvatars.tsx` - New component: avatar circles with user initials, consistent color from user ID hash, tooltip with display_name, max 5 visible with "+N" overflow
|
- src/components/editor/Toolbar.tsx - added isSaving prop with loading spinner indicator
|
||||||
- `src/components/editor/Toolbar.tsx` - Added `presenceUsers` prop and `PresenceAvatars` rendering before connection indicator
|
|
||||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `userDisplayName` prop, `presenceUsers` state, updated `RealtimeConnection` instantiation with `displayName` and `onPresenceSync` callback
|
|
||||||
- `src/app/editor/[projectId]/page.tsx` - Fetches user's `display_name` from profiles table and passes as `userDisplayName` prop
|
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- Supabase Realtime presence uses `channel.track({ ...data })` after subscription to broadcast user info. The presence key is set in channel config: `config: { presence: { key: userId } }`.
|
- Use createClient() from lib/supabase/client.ts for browser-side database operations
|
||||||
- The `presenceState()` returns a `Record<presenceKey, PresencePayload[]>` where each key maps to an array of presence objects. Filter out own key to exclude self.
|
- Supabase update returns { error } object for error handling
|
||||||
- Consistent user colors are generated via a simple hash of the userId string, indexed into a fixed palette of 12 colors. This ensures the same user always gets the same color across sessions.
|
- Use async/await with try/catch for async save operations
|
||||||
- The `PresenceAvatars` component shows initials (first letter of first two words, or first two chars for single-word names) with the computed background color.
|
- Set updated_at manually with new Date().toISOString() for Supabase JSONB updates
|
||||||
- Pre-existing lint errors in ConditionEditor.tsx, OptionConditionEditor.tsx, and ShareModal.tsx are from prior stories and unrelated to this change.
|
- Clear LocalStorage draft after successful save to avoid stale drafts
|
||||||
- No browser testing tools are available; manual verification is needed.
|
- Toast state uses object with message and type for flexibility
|
||||||
|
- Loading spinner SVG with animate-spin class for visual feedback during save
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-048
|
## 2026-01-22 - US-035
|
||||||
- What was implemented: Yjs CRDT integration for conflict-free node/edge synchronization across multiple collaborators
|
- What was implemented: Export project as .vnflow file functionality
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `package.json` / `package-lock.json` - Added `yjs` dependency
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleExport with blob creation and download trigger, added projectName prop
|
||||||
- `src/lib/collaboration/crdt.ts` - New module: `CRDTManager` class wrapping Y.Doc with Y.Map for nodes (keyed by node ID) and Y.Map for edges (keyed by edge ID), broadcast via Supabase Realtime channel, debounced persistence (2s)
|
- src/app/editor/[projectId]/page.tsx - passed projectName prop to FlowchartEditor
|
||||||
- `src/lib/collaboration/realtime.ts` - Added `onChannelSubscribed` callback to `RealtimeCallbacks` type, invoked after channel subscription to allow CRDT connection
|
|
||||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added CRDTManager initialization from DB data, channel connection via `onChannelSubscribed`, bidirectional sync (local→CRDT via useEffect on nodes/edges, remote→local via callbacks), Supabase persistence on debounced timer
|
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- Yjs updates are serialized as `Uint8Array`. For Supabase Realtime broadcast (which uses JSON), convert to `Array.from(update)` for sending and `new Uint8Array(data)` for receiving.
|
- Use Blob with type 'application/json' for JSON file downloads
|
||||||
- The CRDT uses an `isApplyingRemote` flag to prevent echo loops: when applying remote updates, observers skip re-notifying React state (since the state setter is what triggers the change). Similarly, `isRemoteUpdateRef` in FlowchartEditor prevents local→CRDT sync when the change originated from a remote update.
|
- JSON.stringify(data, null, 2) creates pretty-printed JSON with 2-space indentation
|
||||||
- Y.Map stores serialized JSON strings (not objects) as values to avoid Yjs's nested type complexity. This means each node/edge is independently merge-able at the entry level.
|
- URL.createObjectURL creates a temporary URL for the blob
|
||||||
- The `onPersist` callback captures `characters` and `variables` from the effect closure — this means the persisted data always uses the character/variable state at the time the effect was created. For full correctness in a production system, these should be passed dynamically.
|
- Create temporary anchor element with download attribute to trigger file download
|
||||||
- The CRDT document is initialized once from `migratedData` (the lazy-computed initial state). The `initializeFromData` call uses an 'init' origin to distinguish initialization from local edits.
|
- Remember to cleanup: remove the anchor from DOM and revoke the object URL
|
||||||
- The `onChannelSubscribed` callback pattern allows the CRDT to connect to the channel only after it's fully subscribed, avoiding race conditions with broadcast messages arriving before the listener is set up.
|
- Props needed for export: pass data down from server components (e.g., projectName) to client components that need them
|
||||||
- Pre-existing lint errors in ConditionEditor.tsx, OptionConditionEditor.tsx, and ShareModal.tsx are from prior stories and unrelated to this change.
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useMemo, useState } from 'react'
|
||||||
import ReactFlow, {
|
import ReactFlow, {
|
||||||
Background,
|
Background,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
|
|
@ -14,11 +14,16 @@ import ReactFlow, {
|
||||||
Node,
|
Node,
|
||||||
Edge,
|
Edge,
|
||||||
NodeTypes,
|
NodeTypes,
|
||||||
|
EdgeTypes,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
|
NodeMouseHandler,
|
||||||
|
EdgeMouseHandler,
|
||||||
} from 'reactflow'
|
} from 'reactflow'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import 'reactflow/dist/style.css'
|
import 'reactflow/dist/style.css'
|
||||||
import Toolbar from '@/components/editor/Toolbar'
|
import Toolbar from '@/components/editor/Toolbar'
|
||||||
|
import Toast from '@/components/Toast'
|
||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
import DialogueNode from '@/components/editor/nodes/DialogueNode'
|
import DialogueNode from '@/components/editor/nodes/DialogueNode'
|
||||||
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
|
||||||
import VariableNode from '@/components/editor/nodes/VariableNode'
|
import VariableNode from '@/components/editor/nodes/VariableNode'
|
||||||
|
|
@ -35,9 +40,7 @@ import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable,
|
||||||
|
|
||||||
type FlowchartEditorProps = {
|
type FlowchartEditorProps = {
|
||||||
projectId: string
|
projectId: string
|
||||||
userId: string
|
projectName: string
|
||||||
userDisplayName: string
|
|
||||||
isOwner: boolean
|
|
||||||
initialData: FlowchartData
|
initialData: FlowchartData
|
||||||
needsMigration?: boolean
|
needsMigration?: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +64,7 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
|
||||||
target: edge.target,
|
target: edge.target,
|
||||||
targetHandle: edge.targetHandle,
|
targetHandle: edge.targetHandle,
|
||||||
data: edge.data,
|
data: edge.data,
|
||||||
type: 'smoothstep',
|
type: 'conditional',
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
},
|
},
|
||||||
|
|
@ -209,7 +212,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inner component that uses useReactFlow hook
|
// Inner component that uses useReactFlow hook
|
||||||
function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, initialData, needsMigration }: FlowchartEditorProps) {
|
function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) {
|
||||||
// Define custom node types - memoized to prevent re-renders
|
// Define custom node types - memoized to prevent re-renders
|
||||||
const nodeTypes: NodeTypes = useMemo(
|
const nodeTypes: NodeTypes = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -220,7 +223,49 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { getViewport } = useReactFlow()
|
// Define custom edge types - memoized to prevent re-renders
|
||||||
|
const edgeTypes: EdgeTypes = useMemo(
|
||||||
|
() => ({
|
||||||
|
conditional: ConditionalEdge,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const { getViewport, screenToFlowPosition } = useReactFlow()
|
||||||
|
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
||||||
|
const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(null)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error'; action?: { label: string; onClick: () => void } } | null>(null)
|
||||||
|
const [importConfirmDialog, setImportConfirmDialog] = useState<{
|
||||||
|
pendingData: FlowchartData
|
||||||
|
} | null>(null)
|
||||||
|
const [showNavigationWarning, setShowNavigationWarning] = useState(false)
|
||||||
|
|
||||||
|
// Track the last saved data to determine dirty state
|
||||||
|
const lastSavedDataRef = useRef<FlowchartData>(initialData)
|
||||||
|
|
||||||
|
// Ref for hidden file input
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Ref for save function to enable retry without circular dependency
|
||||||
|
const handleSaveRef = useRef<() => void>(() => {})
|
||||||
|
|
||||||
|
// Check for saved draft on initial render (lazy initialization)
|
||||||
|
const [draftState, setDraftState] = useState<{
|
||||||
|
showPrompt: boolean
|
||||||
|
savedDraft: FlowchartData | null
|
||||||
|
}>(() => {
|
||||||
|
// This runs only once on initial render (client-side)
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return { showPrompt: false, savedDraft: null }
|
||||||
|
}
|
||||||
|
const draft = loadDraft(projectId)
|
||||||
|
if (draft && !flowchartDataEquals(draft, initialData)) {
|
||||||
|
return { showPrompt: true, savedDraft: draft }
|
||||||
|
}
|
||||||
|
return { showPrompt: false, savedDraft: null }
|
||||||
|
})
|
||||||
|
|
||||||
// Compute migrated data once on first render using a lazy state initializer
|
// Compute migrated data once on first render using a lazy state initializer
|
||||||
const [migratedData] = useState(() => computeMigration(initialData, !!needsMigration))
|
const [migratedData] = useState(() => computeMigration(initialData, !!needsMigration))
|
||||||
|
|
@ -387,6 +432,78 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini
|
||||||
[nodes, edges]
|
[nodes, edges]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Track debounce timer
|
||||||
|
const saveTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// Debounced auto-save to LocalStorage
|
||||||
|
useEffect(() => {
|
||||||
|
// Don't save while draft prompt is showing
|
||||||
|
if (draftState.showPrompt) return
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timer
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
const currentData: FlowchartData = {
|
||||||
|
nodes: fromReactFlowNodes(nodes),
|
||||||
|
edges: fromReactFlowEdges(edges),
|
||||||
|
}
|
||||||
|
saveDraft(projectId, currentData)
|
||||||
|
}, AUTOSAVE_DEBOUNCE_MS)
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [nodes, edges, projectId, draftState.showPrompt])
|
||||||
|
|
||||||
|
// Calculate dirty state by comparing current data with last saved data
|
||||||
|
const isDirty = useMemo(() => {
|
||||||
|
const currentData: FlowchartData = {
|
||||||
|
nodes: fromReactFlowNodes(nodes),
|
||||||
|
edges: fromReactFlowEdges(edges),
|
||||||
|
}
|
||||||
|
return !flowchartDataEquals(currentData, lastSavedDataRef.current)
|
||||||
|
}, [nodes, edges])
|
||||||
|
|
||||||
|
// Browser beforeunload warning when dirty
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
if (isDirty) {
|
||||||
|
event.preventDefault()
|
||||||
|
// Modern browsers require returnValue to be set
|
||||||
|
event.returnValue = ''
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
}
|
||||||
|
}, [isDirty])
|
||||||
|
|
||||||
|
// Handle restoring draft
|
||||||
|
const handleRestoreDraft = useCallback(() => {
|
||||||
|
if (draftState.savedDraft) {
|
||||||
|
setNodes(toReactFlowNodes(draftState.savedDraft.nodes))
|
||||||
|
setEdges(toReactFlowEdges(draftState.savedDraft.edges))
|
||||||
|
}
|
||||||
|
setDraftState({ showPrompt: false, savedDraft: null })
|
||||||
|
}, [draftState.savedDraft, setNodes, setEdges])
|
||||||
|
|
||||||
|
// Handle discarding draft
|
||||||
|
const handleDiscardDraft = useCallback(() => {
|
||||||
|
clearDraft(projectId)
|
||||||
|
setDraftState({ showPrompt: false, savedDraft: null })
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(params: Connection) => {
|
(params: Connection) => {
|
||||||
if (!params.source || !params.target) return
|
if (!params.source || !params.target) return
|
||||||
|
|
@ -396,7 +513,7 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini
|
||||||
target: params.target,
|
target: params.target,
|
||||||
sourceHandle: params.sourceHandle,
|
sourceHandle: params.sourceHandle,
|
||||||
targetHandle: params.targetHandle,
|
targetHandle: params.targetHandle,
|
||||||
type: 'smoothstep',
|
type: 'conditional',
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
},
|
},
|
||||||
|
|
@ -460,100 +577,205 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini
|
||||||
setNodes((nodes) => [...nodes, newNode])
|
setNodes((nodes) => [...nodes, newNode])
|
||||||
}, [getViewportCenter, setNodes])
|
}, [getViewportCenter, setNodes])
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(async () => {
|
||||||
// TODO: Implement in US-034
|
if (isSaving) return
|
||||||
}, [])
|
|
||||||
|
|
||||||
const performExport = useCallback(() => {
|
setIsSaving(true)
|
||||||
// TODO: Actual export logic in US-035
|
|
||||||
setValidationIssues(null)
|
try {
|
||||||
setWarningNodeIds(new Set())
|
const supabase = createClient()
|
||||||
}, [])
|
|
||||||
|
// Convert React Flow state to FlowchartData
|
||||||
|
const flowchartData: FlowchartData = {
|
||||||
|
nodes: fromReactFlowNodes(nodes),
|
||||||
|
edges: fromReactFlowEdges(edges),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.update({
|
||||||
|
flowchart_data: flowchartData,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', projectId)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear LocalStorage draft after successful save
|
||||||
|
clearDraft(projectId)
|
||||||
|
|
||||||
|
// Update last saved data ref to mark as not dirty
|
||||||
|
lastSavedDataRef.current = flowchartData
|
||||||
|
|
||||||
|
setToast({ message: 'Project saved successfully', type: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save project:', error)
|
||||||
|
setToast({
|
||||||
|
message: 'Failed to save project.',
|
||||||
|
type: 'error',
|
||||||
|
action: { label: 'Retry', onClick: () => { setToast(null); handleSaveRef.current() } },
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}, [isSaving, nodes, edges, projectId])
|
||||||
|
|
||||||
|
// Keep ref updated with latest handleSave
|
||||||
|
handleSaveRef.current = handleSave
|
||||||
|
|
||||||
const handleExport = useCallback(() => {
|
const handleExport = useCallback(() => {
|
||||||
const issues: ValidationIssue[] = []
|
// Convert React Flow state to FlowchartData
|
||||||
const characterIds = new Set(characters.map((c) => c.id))
|
const flowchartData: FlowchartData = {
|
||||||
const variableIds = new Set(variables.map((v) => v.id))
|
nodes: fromReactFlowNodes(nodes),
|
||||||
|
edges: fromReactFlowEdges(edges),
|
||||||
// Scan nodes for undefined references
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
if (node.type === 'dialogue' && node.data?.characterId) {
|
|
||||||
if (!characterIds.has(node.data.characterId)) {
|
|
||||||
issues.push({
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: 'dialogue',
|
|
||||||
contentSnippet: node.data.text
|
|
||||||
? `"${node.data.text.slice(0, 40)}${node.data.text.length > 40 ? '...' : ''}"`
|
|
||||||
: 'Empty dialogue',
|
|
||||||
undefinedReference: node.data.characterId,
|
|
||||||
referenceType: 'character',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (node.type === 'variable' && node.data?.variableId) {
|
|
||||||
if (!variableIds.has(node.data.variableId)) {
|
|
||||||
issues.push({
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: 'variable',
|
|
||||||
contentSnippet: node.data.variableName
|
|
||||||
? `Variable: ${node.data.variableName}`
|
|
||||||
: 'Variable node',
|
|
||||||
undefinedReference: node.data.variableId,
|
|
||||||
referenceType: 'variable',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (node.type === 'choice' && node.data?.options) {
|
|
||||||
node.data.options.forEach((opt: { id: string; label: string; condition?: { variableId?: string; variableName?: string } }) => {
|
|
||||||
if (opt.condition?.variableId && !variableIds.has(opt.condition.variableId)) {
|
|
||||||
issues.push({
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeType: 'choice',
|
|
||||||
contentSnippet: opt.label
|
|
||||||
? `Option: "${opt.label.slice(0, 30)}${opt.label.length > 30 ? '...' : ''}"`
|
|
||||||
: 'Choice option',
|
|
||||||
undefinedReference: opt.condition.variableId,
|
|
||||||
referenceType: 'variable',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Scan edges for undefined variable references in conditions
|
|
||||||
edges.forEach((edge) => {
|
|
||||||
if (edge.data?.condition?.variableId && !variableIds.has(edge.data.condition.variableId)) {
|
|
||||||
issues.push({
|
|
||||||
nodeId: edge.id,
|
|
||||||
nodeType: 'edge',
|
|
||||||
contentSnippet: edge.data.condition.variableName
|
|
||||||
? `Condition on: ${edge.data.condition.variableName}`
|
|
||||||
: 'Edge condition',
|
|
||||||
undefinedReference: edge.data.condition.variableId,
|
|
||||||
referenceType: 'variable',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (issues.length === 0) {
|
|
||||||
performExport()
|
|
||||||
} else {
|
|
||||||
setValidationIssues(issues)
|
|
||||||
setWarningNodeIds(new Set(issues.map((i) => i.nodeId)))
|
|
||||||
}
|
}
|
||||||
}, [nodes, edges, characters, variables, performExport])
|
|
||||||
|
|
||||||
const handleExportAnyway = useCallback(() => {
|
// Create pretty-printed JSON
|
||||||
performExport()
|
const jsonContent = JSON.stringify(flowchartData, null, 2)
|
||||||
}, [performExport])
|
|
||||||
|
|
||||||
const handleExportCancel = useCallback(() => {
|
// Create blob with JSON content
|
||||||
setValidationIssues(null)
|
const blob = new Blob([jsonContent], { type: 'application/json' })
|
||||||
setWarningNodeIds(new Set())
|
|
||||||
|
// Create download URL
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
// Create temporary link element and trigger download
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `${projectName}.vnflow`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}, [nodes, edges, projectName])
|
||||||
|
|
||||||
|
const handleExportRenpy = useCallback(() => {
|
||||||
|
// Convert React Flow state to our flowchart types
|
||||||
|
const flowchartNodes = fromReactFlowNodes(nodes)
|
||||||
|
const flowchartEdges = fromReactFlowEdges(edges)
|
||||||
|
|
||||||
|
// Convert to Ren'Py format
|
||||||
|
const renpyExport = convertToRenpyFormat(flowchartNodes, flowchartEdges, projectName)
|
||||||
|
|
||||||
|
// Create pretty-printed JSON
|
||||||
|
const jsonContent = JSON.stringify(renpyExport, null, 2)
|
||||||
|
|
||||||
|
// Verify JSON is valid
|
||||||
|
try {
|
||||||
|
JSON.parse(jsonContent)
|
||||||
|
} catch {
|
||||||
|
setToast({ message: 'Failed to generate valid JSON', type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create blob with JSON content
|
||||||
|
const blob = new Blob([jsonContent], { type: 'application/json' })
|
||||||
|
|
||||||
|
// Create download URL
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
// Create temporary link element and trigger download
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `${projectName}-renpy.json`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
setToast({ message: 'Exported to Ren\'Py format successfully', type: 'success' })
|
||||||
|
}, [nodes, edges, projectName])
|
||||||
|
|
||||||
|
// Check if current flowchart has unsaved changes
|
||||||
|
const hasUnsavedChanges = useCallback(() => {
|
||||||
|
const currentData: FlowchartData = {
|
||||||
|
nodes: fromReactFlowNodes(nodes),
|
||||||
|
edges: fromReactFlowEdges(edges),
|
||||||
|
}
|
||||||
|
return !flowchartDataEquals(currentData, initialData)
|
||||||
|
}, [nodes, edges, initialData])
|
||||||
|
|
||||||
|
// Load imported data into React Flow
|
||||||
|
const loadImportedData = useCallback(
|
||||||
|
(data: FlowchartData) => {
|
||||||
|
setNodes(toReactFlowNodes(data.nodes))
|
||||||
|
setEdges(toReactFlowEdges(data.edges))
|
||||||
|
setToast({ message: 'Project imported successfully', type: 'success' })
|
||||||
|
},
|
||||||
|
[setNodes, setEdges]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle file selection from file picker
|
||||||
|
const handleFileSelect = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Reset file input so same file can be selected again
|
||||||
|
event.target.value = ''
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const content = e.target?.result as string
|
||||||
|
const parsedData = JSON.parse(content)
|
||||||
|
|
||||||
|
// Validate the imported data
|
||||||
|
if (!isValidFlowchartData(parsedData)) {
|
||||||
|
setToast({
|
||||||
|
message: 'Invalid file format. File must contain nodes and edges arrays.',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current project has unsaved changes
|
||||||
|
if (hasUnsavedChanges()) {
|
||||||
|
// Show confirmation dialog
|
||||||
|
setImportConfirmDialog({ pendingData: parsedData })
|
||||||
|
} else {
|
||||||
|
// Load data directly
|
||||||
|
loadImportedData(parsedData)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setToast({
|
||||||
|
message: 'Failed to parse file. Please ensure it is valid JSON.',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
setToast({ message: 'Failed to read file.', type: 'error' })
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsText(file)
|
||||||
|
},
|
||||||
|
[hasUnsavedChanges, loadImportedData]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle import button click - opens file picker
|
||||||
|
const handleImport = useCallback(() => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleImport = useCallback(() => {
|
// Confirm import (discard unsaved changes)
|
||||||
// TODO: Implement in US-036
|
const handleConfirmImport = useCallback(() => {
|
||||||
|
if (importConfirmDialog?.pendingData) {
|
||||||
|
loadImportedData(importConfirmDialog.pendingData)
|
||||||
|
}
|
||||||
|
setImportConfirmDialog(null)
|
||||||
|
}, [importConfirmDialog, loadImportedData])
|
||||||
|
|
||||||
|
// Cancel import
|
||||||
|
const handleCancelImport = useCallback(() => {
|
||||||
|
setImportConfirmDialog(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Handle edge deletion via keyboard (Delete/Backspace)
|
// Handle edge deletion via keyboard (Delete/Backspace)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
|
||||||
export default function EditorLoading() {
|
export default function EditorLoading() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
|
|
@ -9,31 +11,7 @@ export default function EditorLoading() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
<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">
|
<LoadingSpinner size="lg" message="Loading editor..." />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import FlowchartEditor from './FlowchartEditor'
|
import FlowchartEditor from './FlowchartEditor'
|
||||||
import type { FlowchartData } from '@/types/flowchart'
|
import type { FlowchartData } from '@/types/flowchart'
|
||||||
|
|
@ -80,13 +79,14 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
// the project was created before these features existed and may need auto-migration
|
// the project was created before these features existed and may need auto-migration
|
||||||
const needsMigration = !rawData.characters && !rawData.variables
|
const needsMigration = !rawData.characters && !rawData.variables
|
||||||
|
|
||||||
return (
|
return (<>
|
||||||
<div className="flex h-screen flex-col">
|
<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">
|
<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">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||||
|
aria-label="Back to dashboard"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|
@ -101,9 +101,35 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
</header>
|
||||||
{project.name}
|
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
||||||
</h1>
|
<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>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -117,6 +143,20 @@ export default async function EditorPage({ params }: PageProps) {
|
||||||
needsMigration={needsMigration}
|
needsMigration={needsMigration}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowchartData = (project.flowchart_data || {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
}) as FlowchartData
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlowchartEditor
|
||||||
|
projectId={project.id}
|
||||||
|
projectName={project.name}
|
||||||
|
initialData={flowchartData}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,130 +1,25 @@
|
||||||
'use client'
|
import { Suspense } from 'react'
|
||||||
|
import LoginForm from './LoginForm'
|
||||||
import { useState } from 'react'
|
import PasswordResetModal from '@/components/PasswordResetModal'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { createClient } from '@/lib/supabase/client'
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
<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">
|
<Suspense
|
||||||
<div className="text-center">
|
fallback={
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
<div className="w-full max-w-md space-y-8 text-center">
|
||||||
WebVNWrite
|
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
</h1>
|
WebVNWrite
|
||||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
</h1>
|
||||||
Sign in to your account
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
</p>
|
Loading...
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<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>
|
||||||
|
}
|
||||||
<div className="flex items-center justify-end">
|
>
|
||||||
<Link
|
<LoginForm />
|
||||||
href="/forgot-password"
|
</Suspense>
|
||||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
<PasswordResetModal />
|
||||||
>
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,5 @@
|
||||||
import Image from "next/image";
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
redirect('/dashboard')
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,238 +1,23 @@
|
||||||
'use client'
|
import { Suspense } from 'react'
|
||||||
|
import SignupForm from './SignupForm'
|
||||||
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 SignupPage() {
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
<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">
|
<Suspense
|
||||||
<div className="text-center">
|
fallback={
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
<div className="w-full max-w-md space-y-8 text-center">
|
||||||
WebVNWrite
|
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
</h1>
|
WebVNWrite
|
||||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
</h1>
|
||||||
Complete your account setup
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
</p>
|
Loading...
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
}
|
||||||
<button
|
>
|
||||||
type="submit"
|
<SignupForm />
|
||||||
disabled={loading}
|
</Suspense>
|
||||||
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>
|
|
||||||
</div>
|
</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
|
Invite User
|
||||||
</Link>
|
</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">
|
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
{userEmail}
|
{userEmail}
|
||||||
</span>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,16 +6,23 @@ interface ToastProps {
|
||||||
message: string
|
message: string
|
||||||
type: 'success' | 'error'
|
type: 'success' | 'error'
|
||||||
onClose: () => void
|
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(() => {
|
useEffect(() => {
|
||||||
|
// Don't auto-dismiss if there's an action button
|
||||||
|
if (action) return
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
onClose()
|
onClose()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [onClose])
|
}, [onClose, action])
|
||||||
|
|
||||||
const bgColor =
|
const bgColor =
|
||||||
type === 'success'
|
type === 'success'
|
||||||
|
|
@ -60,6 +67,14 @@ export default function Toast({ message, type, onClose }: ToastProps) {
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span>{message}</span>
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="ml-2 rounded p-0.5 hover:bg-white/20"
|
className="ml-2 rounded p-0.5 hover:bg-white/20"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ type ToolbarProps = {
|
||||||
onAddVariable: () => void
|
onAddVariable: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
|
onExportRenpy: () => void
|
||||||
onImport: () => void
|
onImport: () => void
|
||||||
onProjectSettings: () => void
|
onProjectSettings: () => void
|
||||||
onShare: () => void
|
onShare: () => void
|
||||||
|
|
@ -36,6 +37,7 @@ export default function Toolbar({
|
||||||
onAddVariable,
|
onAddVariable,
|
||||||
onSave,
|
onSave,
|
||||||
onExport,
|
onExport,
|
||||||
|
onExportRenpy,
|
||||||
onImport,
|
onImport,
|
||||||
onProjectSettings,
|
onProjectSettings,
|
||||||
onShare,
|
onShare,
|
||||||
|
|
@ -96,9 +98,32 @@ export default function Toolbar({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Save
|
{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>
|
||||||
<button
|
<button
|
||||||
onClick={onExport}
|
onClick={onExport}
|
||||||
|
|
@ -106,6 +131,12 @@ export default function Toolbar({
|
||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={onImport}
|
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"
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,6 @@ const MAX_OPTIONS = 6
|
||||||
|
|
||||||
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
const { variables } = useEditorContext()
|
|
||||||
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
||||||
|
|
||||||
const updatePrompt = useCallback(
|
const updatePrompt = useCallback(
|
||||||
|
|
@ -228,13 +227,95 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.options.map((option, index) => (
|
||||||
|
<div key={option.id}>
|
||||||
|
<div className="relative 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingConditionOptionId(option.id)}
|
||||||
|
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
||||||
|
option.condition
|
||||||
|
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/50 dark:text-amber-300 dark:hover:bg-amber-900/70'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:text-zinc-500 dark:hover:bg-zinc-700 dark:hover:text-zinc-300'
|
||||||
|
}`}
|
||||||
|
title={option.condition ? `Condition: ${option.condition.variableName} ${option.condition.operator} ${option.condition.value}` : 'Add condition'}
|
||||||
|
>
|
||||||
|
{option.condition ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<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}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{option.condition && (
|
||||||
|
<div className="ml-1 mt-0.5 flex items-center gap-1">
|
||||||
|
<span className="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
||||||
|
if {option.condition.variableName} {option.condition.operator} {option.condition.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
+ Add Option
|
||||||
|
</button>
|
||||||
|
|
||||||
{editingOption && (
|
{editingOption && (
|
||||||
<OptionConditionEditor
|
<OptionConditionEditor
|
||||||
|
optionId={editingOption.id}
|
||||||
|
optionLabel={editingOption.label}
|
||||||
condition={editingOption.condition}
|
condition={editingOption.condition}
|
||||||
onChange={(condition) => updateOptionCondition(editingOption.id, condition)}
|
onSave={handleSaveCondition}
|
||||||
onClose={() => setEditingConditionOptionId(null)}
|
onRemove={handleRemoveCondition}
|
||||||
|
onCancel={() => setEditingConditionOptionId(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue