manage conflict

This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-23 11:42:18 -03:00
commit b8ad32dc17
22 changed files with 3182 additions and 457 deletions

342
prd.json
View File

@ -414,8 +414,350 @@
"Verify in browser using dev-browser skill"
],
"priority": 23,
<<<<<<< HEAD
"passes": false,
"notes": "Dependencies: US-052, US-048"
=======
"passes": true,
"notes": ""
},
{
"id": "US-024",
"title": "Add/remove choice options",
"description": "As a user, I want to add or remove choice options (2-6 options supported).",
"acceptanceCriteria": [
"ChoiceNode has '+' button to add new option",
"Maximum 6 options (button disabled or hidden at max)",
"Each option has 'x' button to remove it",
"Minimum 2 options (remove button disabled or hidden at min)",
"Adding option creates new output Handle dynamically",
"Removing option removes its Handle",
"Node data updates in React Flow state",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 24,
"passes": true,
"notes": ""
},
{
"id": "US-025",
"title": "Create custom variable node component",
"description": "As a user, I want variable nodes to set or modify story variables.",
"acceptanceCriteria": [
"Create components/editor/nodes/VariableNode.tsx",
"Node styled with orange background/border",
"Displays editable input for variable name (placeholder: 'variableName')",
"Displays dropdown/select for operation: set, add, subtract",
"Displays editable number input for value (default: 0)",
"Has one Handle at top (type='target', id='input')",
"Has one Handle at bottom (type='source', id='output')",
"Register as custom node type in React Flow",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 25,
"passes": true,
"notes": ""
},
{
"id": "US-026",
"title": "Add variable node from toolbar",
"description": "As a user, I want to add variable nodes by clicking the toolbar button.",
"acceptanceCriteria": [
"Clicking 'Add Variable' in toolbar creates new VariableNode",
"Node appears at center of current viewport",
"Node has unique ID",
"Node initialized with empty variableName, operation='set', value=0",
"Node added to React Flow nodes state",
"Node can be dragged to reposition",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 26,
"passes": true,
"notes": ""
},
{
"id": "US-027",
"title": "Connect nodes with edges",
"description": "As a user, I want to connect nodes with arrows to define story flow.",
"acceptanceCriteria": [
"Dragging from source Handle to target Handle creates edge (React Flow default)",
"Edges render as smooth bezier curves (default edge type or smoothstep)",
"Edges show arrow marker indicating direction (markerEnd)",
"Edges update position when nodes are moved",
"Cannot connect source-to-source or target-to-target (React Flow handles this)",
"New edges added to React Flow edges state",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 27,
"passes": true,
"notes": ""
},
{
"id": "US-028",
"title": "Select and delete nodes",
"description": "As a user, I want to delete nodes to revise my flowchart.",
"acceptanceCriteria": [
"Clicking a node selects it (visual highlight via React Flow)",
"Pressing Delete or Backspace key removes selected node(s)",
"Deleting node also removes all connected edges",
"Use onNodesDelete callback to handle deletion",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 28,
"passes": true,
"notes": ""
},
{
"id": "US-029",
"title": "Select and delete edges",
"description": "As a user, I want to delete connections between nodes.",
"acceptanceCriteria": [
"Clicking an edge selects it (visual highlight via React Flow)",
"Pressing Delete or Backspace key removes selected edge(s)",
"Use onEdgesDelete callback to handle deletion",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 29,
"passes": true,
"notes": ""
},
{
"id": "US-030",
"title": "Right-click context menu",
"description": "As a user, I want a context menu for quick actions.",
"acceptanceCriteria": [
"Create components/editor/ContextMenu.tsx",
"Right-click on canvas shows menu: Add Dialogue, Add Choice, Add Variable",
"New node created at click position",
"Right-click on node shows menu: Delete",
"Right-click on edge shows menu: Delete, Add Condition",
"Clicking elsewhere or pressing Escape closes menu",
"Menu styled with TailwindCSS",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 30,
"passes": true,
"notes": ""
},
{
"id": "US-031",
"title": "Condition editor modal",
"description": "As a user, I want to add conditions to edges so branches depend on variables.",
"acceptanceCriteria": [
"Create components/editor/ConditionEditor.tsx modal/popover",
"Opens on double-click edge or via context menu 'Add Condition'",
"Form fields: variable name input, operator dropdown (>, <, ==, >=, <=, !=), value number input",
"Pre-fill fields if edge already has condition",
"Save button applies condition to edge data",
"Clear/Remove button removes condition from edge",
"Cancel button closes without saving",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 31,
"passes": true,
"notes": ""
},
{
"id": "US-032",
"title": "Display conditions on edges",
"description": "As a user, I want to see conditions displayed on edges.",
"acceptanceCriteria": [
"Create custom edge component or use edge labels",
"Edges with conditions render as dashed lines (strokeDasharray)",
"Condition label displayed on edge (e.g., 'score > 5')",
"Unconditional edges remain solid lines",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 32,
"passes": true,
"notes": ""
},
{
"id": "US-033",
"title": "Auto-save to LocalStorage",
"description": "As a user, I want my work auto-saved locally so I don't lose progress if the browser crashes.",
"acceptanceCriteria": [
"Save flowchart state (nodes + edges) to LocalStorage on every change",
"Debounce saves (e.g., 1 second delay after last change)",
"LocalStorage key format: 'vnwrite-draft-{projectId}'",
"On editor load, check LocalStorage for saved draft",
"If local draft exists and differs from database, show prompt to restore or discard",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 33,
"passes": true,
"notes": ""
},
{
"id": "US-034",
"title": "Save project to database",
"description": "As a user, I want to save my project to the database manually.",
"acceptanceCriteria": [
"Clicking 'Save' in toolbar saves current nodes/edges to Supabase",
"Update project's flowchart_data and updated_at fields",
"Show saving indicator/spinner while in progress",
"Show success toast on completion",
"Clear LocalStorage draft after successful save",
"Show error toast if save fails",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 34,
"passes": true,
"notes": ""
},
{
"id": "US-035",
"title": "Export project as .vnflow file",
"description": "As a user, I want to export my project as a JSON file for backup or sharing.",
"acceptanceCriteria": [
"Clicking 'Export' in toolbar triggers file download",
"File named '[project-name].vnflow'",
"File contains JSON with nodes and edges arrays",
"JSON is pretty-printed (2-space indent) for readability",
"Uses browser download API (create blob, trigger download)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 35,
"passes": true,
"notes": ""
},
{
"id": "US-036",
"title": "Import project from .vnflow file",
"description": "As a user, I want to import a .vnflow file to restore or share projects.",
"acceptanceCriteria": [
"Clicking 'Import' in toolbar opens file picker",
"Accept .vnflow and .json file extensions",
"If current project has unsaved changes, show confirmation dialog",
"Validate imported file has nodes and edges arrays",
"Show error toast if file is invalid",
"Load valid data into React Flow state (replaces current flowchart)",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 36,
"passes": true,
"notes": ""
},
{
"id": "US-037",
"title": "Export to Ren'Py JSON format",
"description": "As a user, I want to export my flowchart to Ren'Py-compatible JSON for use in my game.",
"acceptanceCriteria": [
"Add 'Export to Ren'Py' option (button or dropdown item)",
"File named '[project-name]-renpy.json'",
"Dialogue nodes export as: { type: 'dialogue', speaker: '...', text: '...' }",
"Choice nodes export as: { type: 'menu', prompt: '...', choices: [{ label: '...', next: '...' }] }",
"Variable nodes export as: { type: 'variable', name: '...', operation: '...', value: ... }",
"Edges with conditions include condition object on the choice/jump",
"Organize nodes into labeled sections based on flow (traverse from first node)",
"Include metadata: projectName, exportedAt timestamp",
"Output JSON is valid (test with JSON.parse)",
"Typecheck passes"
],
"priority": 37,
"passes": true,
"notes": ""
},
{
"id": "US-038",
"title": "Unsaved changes warning",
"description": "As a user, I want a warning before losing unsaved work.",
"acceptanceCriteria": [
"Track dirty state: true when flowchart modified after last save",
"Set dirty=true on node/edge add, delete, or modify",
"Set dirty=false after successful save",
"Browser beforeunload event shows warning if dirty",
"Navigating to dashboard shows confirmation modal if dirty",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 38,
"passes": true,
"notes": ""
},
{
"id": "US-039",
"title": "Loading and error states",
"description": "As a user, I want clear feedback when things are loading or when errors occur.",
"acceptanceCriteria": [
"Loading spinner component for async operations",
"Editor shows loading spinner while fetching project",
"Error message displayed if project fails to load (with back to dashboard link)",
"Toast notification system for success/error messages",
"Save error shows toast with retry option",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 39,
"passes": true,
"notes": ""
},
{
"id": "US-040",
"title": "Conditionals on choice options",
"description": "As a user, I want individual choice options to have variable conditions so that options are only visible when certain conditions are met (e.g., affection > 10).",
"acceptanceCriteria": [
"Each ChoiceOption can have optional condition (variableName, operator, value)",
"Update ChoiceNode UI to show 'Add condition' button per option",
"Condition editor modal for each option",
"Visual indicator (icon/badge) on options with conditions",
"Update TypeScript types: ChoiceOption gets optional condition field",
"Export includes per-option conditions in Ren'Py JSON",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 40,
"passes": true,
"notes": "Dependencies: US-018, US-019, US-025. Complexity: M"
},
{
"id": "US-041",
"title": "Change password for logged-in user",
"description": "As a user, I want to change my own password from a settings/profile page so that I can keep my account secure.",
"acceptanceCriteria": [
"Settings/profile page accessible from dashboard header",
"Form with: current password, new password, confirm new password fields",
"Calls Supabase updateUser with new password",
"Requires current password verification (re-authenticate)",
"Shows success/error messages",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 41,
"passes": true,
"notes": "Dependencies: US-004. Complexity: S"
},
{
"id": "US-042",
"title": "Password reset modal on token arrival",
"description": "As a user, I want a modal to automatically appear when a password reset token is detected so that I can set my new password seamlessly.",
"acceptanceCriteria": [
"Detect password reset token in URL (from Supabase email link)",
"Show modal/dialog automatically when token present",
"Modal has: new password, confirm password fields",
"Calls Supabase updateUser with token to complete reset",
"On success, close modal and redirect to login",
"On error, show error message",
"Typecheck passes",
"Verify in browser using dev-browser skill"
],
"priority": 42,
"passes": true,
"notes": "Dependencies: US-006. Complexity: S"
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
}
]
}

View File

@ -27,6 +27,7 @@
- Reusable LoadingSpinner component in `src/components/LoadingSpinner.tsx` with size ('sm'|'md'|'lg') and optional message
- Toast component supports an optional `action` prop: `{ label: string; onClick: () => void }` for retry/undo buttons
- Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser
<<<<<<< HEAD
- Character/Variable types (`Character`, `Variable`) and extracted node data types (`DialogueNodeData`, `VariableNodeData`) are in `src/types/flowchart.ts`
- `EditorContext` at `src/components/editor/EditorContext.tsx` provides shared state (characters, onAddCharacter) to all custom node components via React context
- Use `useEditorContext()` in node components to access project-level characters and variables without prop drilling through React Flow node data
@ -43,6 +44,8 @@
- `ChoiceOption` type includes optional `condition?: Condition`. When counting variable usage, check variable nodes + edge conditions + choice option conditions.
- React Compiler lint forbids `setState` in effects and reading `useRef().current` during render. Use `useState(() => computeValue())` lazy initializer pattern for one-time initialization logic.
- For detecting legacy data shape (pre-migration), pass a flag from the server component (page.tsx) to the client component, since only the server reads raw DB data.
=======
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
---
@ -183,3 +186,199 @@
- Supabase client-side fetching from `createClient()` (browser) automatically scopes by the logged-in user's RLS policies, so fetching other projects just uses `.neq('id', currentProjectId)` and RLS handles ownership filtering.
- No browser testing tools are available; manual verification is needed.
---
## 2026-01-22 - US-030
- What was implemented: Right-click context menu for canvas, nodes, and edges
- Files changed:
- src/components/editor/ContextMenu.tsx - new component with menu items for different contexts (canvas/node/edge)
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated context menu with handlers for all actions
- **Learnings for future iterations:**
- Use `onPaneContextMenu`, `onNodeContextMenu`, and `onEdgeContextMenu` React Flow callbacks for context menus
- `screenToFlowPosition()` converts screen coordinates to flow coordinates for placing nodes at click position
- Context menu state includes type ('canvas'|'node'|'edge') and optional nodeId/edgeId for targeted actions
- Use `document.addEventListener('click', handler)` and `e.stopPropagation()` on menu to close on outside click
- Escape key listener via `document.addEventListener('keydown', handler)` for menu close
- NodeMouseHandler and EdgeMouseHandler types from reactflow provide proper typing for context menu callbacks
---
## 2026-01-22 - US-031
- What was implemented: Condition editor modal for adding/editing/removing conditions on edges
- Files changed:
- src/components/editor/ConditionEditor.tsx - new modal component with form for variable name, operator, and value
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated condition editor with double-click and context menu triggers
- **Learnings for future iterations:**
- Use `onEdgeDoubleClick` React Flow callback for double-click on edges
- Store condition editor state separately from context menu state (`conditionEditor` vs `contextMenu`)
- Use `edge.data.condition` to access condition object on edges
- When removing properties from edge data, use `delete` operator instead of destructuring to avoid lint warnings about unused variables
- Condition type has operators: '>' | '<' | '==' | '>=' | '<=' | '!='
- Preview condition in modal using template string: `${variableName} ${operator} ${value}`
---
## 2026-01-22 - US-032
- What was implemented: Display conditions on edges with dashed styling and labels
- Files changed:
- src/components/editor/edges/ConditionalEdge.tsx - new custom edge component with condition display
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated custom edge type, added EdgeTypes import and edgeTypes definition
- **Learnings for future iterations:**
- Custom React Flow edges use EdgeProps<T> typing where T is the data shape
- Use `BaseEdge` component for rendering the edge path, and `EdgeLabelRenderer` for positioning labels
- `getSmoothStepPath` returns [edgePath, labelX, labelY] - labelX/labelY are center coordinates for labels
- Custom edge types are registered in edgeTypes object (similar to nodeTypes) and passed to ReactFlow
- Style edges with conditions using strokeDasharray: '5 5' for dashed lines
- Custom edges go in `src/components/editor/edges/` directory
- Use amber color scheme for conditional edges to distinguish from regular edges
---
## 2026-01-22 - US-033
- What was implemented: Auto-save to LocalStorage with debounced saves and draft restoration prompt
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added LocalStorage auto-save functionality, draft check on load, and restoration prompt UI
- **Learnings for future iterations:**
- Use lazy useState initializer for draft check to avoid ESLint "setState in effect" warning
- LocalStorage key format: `vnwrite-draft-{projectId}` for project-specific drafts
- Debounce saves with 1 second delay using useRef for timer tracking
- Convert React Flow Node/Edge types back to app types using helper functions (fromReactFlowNodes, fromReactFlowEdges)
- React Flow Edge has `sourceHandle: string | null | undefined` but app types use `string | undefined` - use nullish coalescing (`?? undefined`)
- Check `typeof window === 'undefined'` in lazy initializer for SSR safety
- clearDraft is exported for use in save functionality (US-034) to clear draft after successful database save
- JSON.stringify comparison works for flowchart data equality check
---
## 2026-01-22 - US-034
- What was implemented: Save project to database functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleSave with Supabase update, added isSaving state and Toast notifications
- src/components/editor/Toolbar.tsx - added isSaving prop with loading spinner indicator
- **Learnings for future iterations:**
- Use createClient() from lib/supabase/client.ts for browser-side database operations
- Supabase update returns { error } object for error handling
- Use async/await with try/catch for async save operations
- Set updated_at manually with new Date().toISOString() for Supabase JSONB updates
- Clear LocalStorage draft after successful save to avoid stale drafts
- Toast state uses object with message and type for flexibility
- Loading spinner SVG with animate-spin class for visual feedback during save
---
## 2026-01-22 - US-035
- What was implemented: Export project as .vnflow file functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleExport with blob creation and download trigger, added projectName prop
- src/app/editor/[projectId]/page.tsx - passed projectName prop to FlowchartEditor
- **Learnings for future iterations:**
- Use Blob with type 'application/json' for JSON file downloads
- JSON.stringify(data, null, 2) creates pretty-printed JSON with 2-space indentation
- URL.createObjectURL creates a temporary URL for the blob
- Create temporary anchor element with download attribute to trigger file download
- Remember to cleanup: remove the anchor from DOM and revoke the object URL
- Props needed for export: pass data down from server components (e.g., projectName) to client components that need them
---
## 2026-01-22 - US-036
- What was implemented: Import project from .vnflow file functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleImport, handleFileSelect, validation, and confirmation dialog for unsaved changes
- **Learnings for future iterations:**
- Use hidden `<input type="file">` element with ref to trigger file picker programmatically via `ref.current?.click()`
- Accept multiple file extensions using comma-separated values in `accept` attribute: `accept=".vnflow,.json"`
- Reset file input value after selection (`event.target.value = ''`) to allow re-selecting the same file
- Use FileReader API with `readAsText()` for reading file contents, handle onload and onerror callbacks
- Type guard function `isValidFlowchartData()` validates imported JSON structure before loading
- Track unsaved changes by comparing current state to initialData using JSON.stringify comparison
- Show confirmation dialog before import if there are unsaved changes to prevent accidental data loss
---
## 2026-01-22 - US-037
- What was implemented: Export to Ren'Py JSON format functionality
- Files changed:
- src/components/editor/Toolbar.tsx - added 'Export to Ren'Py' button with purple styling
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented Ren'Py export types, conversion functions, and handleExportRenpy callback
- **Learnings for future iterations:**
- Ren'Py export uses typed interfaces for different node types: RenpyDialogueNode, RenpyMenuNode, RenpyVariableNode
- Find first node by identifying nodes with no incoming edges (not in any edge's target set)
- Use graph traversal (DFS) to organize nodes into labeled sections based on flow
- Choice nodes create branching sections - save current section before processing each branch
- Track visited nodes to detect cycles and create proper labels for jump references
- Labels are generated based on speaker name or incremental counter for uniqueness
- Replace node IDs with proper labels in a second pass after traversal completes
- Include metadata (projectName, exportedAt) at the top level of the export
- Validate JSON output with JSON.parse before download to ensure validity
- Use purple color scheme for Ren'Py-specific button to distinguish from generic export
---
## 2026-01-22 - US-038
- What was implemented: Unsaved changes warning with dirty state tracking, beforeunload, and navigation confirmation modal
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - added isDirty tracking via useMemo comparing current state to lastSavedDataRef, beforeunload event handler, navigation warning modal, back button with handleBackClick, moved header from page.tsx into this component
- src/app/editor/[projectId]/page.tsx - simplified to only render FlowchartEditor (header moved to client component for dirty state access)
- **Learnings for future iterations:**
- Dirty state tracking uses useMemo comparing JSON.stringify of current flowchart data to a lastSavedDataRef
- lastSavedDataRef is a useRef initialized with initialData and updated after successful save
- Browser beforeunload requires both event.preventDefault() and setting event.returnValue = '' for modern browsers
- Header with back navigation was moved from server component (page.tsx) to client component (FlowchartEditor.tsx) so it can access isDirty state
- Back button uses handleBackClick which checks isDirty before navigating or showing confirmation modal
- Navigation warning modal shows "Leave Page" (red) and "Stay" buttons for clear user action
- "(unsaved changes)" indicator shown next to project name when isDirty is true
---
## 2026-01-22 - US-039
- What was implemented: Loading and error states with reusable spinner, error page, and toast retry
- Files changed:
- src/components/LoadingSpinner.tsx - new reusable loading spinner component with size variants and optional message
- src/app/editor/[projectId]/loading.tsx - updated to use LoadingSpinner component
- src/app/editor/[projectId]/page.tsx - replaced notFound() with custom error UI showing "Project Not Found" with back to dashboard link
- src/components/Toast.tsx - added optional action prop for action buttons (e.g., retry)
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated toast state type to include action, save error now shows retry button via handleSaveRef pattern
- **Learnings for future iterations:**
- Use a ref (handleSaveRef) to break circular dependency when a useCallback needs to reference itself for retry logic
- Toast action prop uses `{ label: string; onClick: () => void }` for flexible action buttons
- Don't auto-dismiss toasts that have action buttons (users need time to click them)
- Replace `notFound()` with inline error UI when you need custom styling and navigation links
- LoadingSpinner uses size prop ('sm' | 'md' | 'lg') for flexibility across different contexts
- Link component from next/link is needed in server components for navigation (no useRouter in server components)
---
## 2026-01-22 - US-040
- What was implemented: Conditionals on choice options - per-option visibility conditions
- Files changed:
- src/types/flowchart.ts - moved Condition type before ChoiceOption, added optional condition field to ChoiceOption
- src/components/editor/nodes/ChoiceNode.tsx - added condition button per option, condition badge display, condition editing state management
- src/components/editor/OptionConditionEditor.tsx - new modal component for editing per-option conditions (variable name, operator, value)
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated Ren'Py export to include per-option conditions (option condition takes priority over edge condition)
- **Learnings for future iterations:**
- Per-option conditions use the same Condition type as edge conditions
- Condition type needed to be moved above ChoiceOption in types file since ChoiceOption now references it
- Use `delete obj.property` pattern instead of destructuring with unused variable to avoid lint warnings
- OptionConditionEditor is separate from ConditionEditor because it operates on option IDs vs edge IDs
- In Ren'Py export, option-level condition takes priority over edge condition since it represents visibility
- Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not
- Condition badge below option shows "if variableName operator value" text in compact format
---
## 2026-01-22 - US-041
- What was implemented: Change password for logged-in user from settings page
- Files changed:
- src/app/dashboard/settings/page.tsx - new client component with password change form (current, new, confirm fields)
- src/components/Navbar.tsx - added "Settings" link to navbar
- **Learnings for future iterations:**
- Settings page lives under /dashboard/settings to reuse the dashboard layout (navbar, auth check)
- Re-authentication uses signInWithPassword with current password before allowing updateUser
- Supabase getUser() returns current user email needed for re-auth signInWithPassword call
- Password validation: check match and minimum length (6 chars) before making API calls
- Clear form fields after successful password update for security
- Settings link in navbar uses neutral zinc colors to distinguish from admin/action links
---
## 2026-01-22 - US-042
- What was implemented: Password reset modal that automatically appears when a recovery token is detected in the URL
- Files changed:
- src/components/PasswordResetModal.tsx - new client component with modal that detects recovery tokens from URL hash, sets session, and provides password reset form
- src/app/login/page.tsx - integrated PasswordResetModal component on the login page
- **Learnings for future iterations:**
- PasswordResetModal is a standalone component that can be placed on any page to detect recovery tokens
- Use window.history.replaceState to clean the URL hash after extracting the token (prevents re-triggering on refresh)
- Separate tokenError state from form error state to show different UI (expired link vs. form validation)
- Modal uses fixed positioning with z-50 to overlay above page content
- After successful password update, sign out the user and redirect to login with success message (same as reset-password page)
- The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns
---

View File

@ -0,0 +1,161 @@
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export default function SettingsPage() {
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess('')
if (newPassword !== confirmPassword) {
setError('New passwords do not match.')
return
}
if (newPassword.length < 6) {
setError('New password must be at least 6 characters.')
return
}
setIsLoading(true)
try {
const supabase = createClient()
// Re-authenticate with current password
const { data: { user } } = await supabase.auth.getUser()
if (!user?.email) {
setError('Unable to verify current user.')
setIsLoading(false)
return
}
const { error: signInError } = await supabase.auth.signInWithPassword({
email: user.email,
password: currentPassword,
})
if (signInError) {
setError('Current password is incorrect.')
setIsLoading(false)
return
}
// Update to new password
const { error: updateError } = await supabase.auth.updateUser({
password: newPassword,
})
if (updateError) {
setError(updateError.message)
setIsLoading(false)
return
}
setSuccess('Password updated successfully.')
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
} catch {
setError('An unexpected error occurred.')
} finally {
setIsLoading(false)
}
}
return (
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 mb-8">
Settings
</h1>
<div className="max-w-md">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50 mb-4">
Change Password
</h2>
<form onSubmit={handleChangePassword} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
{error}
</div>
)}
{success && (
<div className="rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-400">
{success}
</div>
)}
<div>
<label
htmlFor="currentPassword"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
>
Current Password
</label>
<input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
/>
</div>
<div>
<label
htmlFor="newPassword"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
>
New Password
</label>
<input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
>
Confirm New Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-zinc-900"
>
{isLoading ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
import LoadingSpinner from '@/components/LoadingSpinner'
export default function EditorLoading() {
return (
<div className="flex h-screen flex-col">
@ -9,31 +11,7 @@ export default function EditorLoading() {
</header>
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
<div className="flex flex-col items-center gap-3">
<svg
className="h-8 w-8 animate-spin text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Loading editor...
</p>
</div>
<LoadingSpinner size="lg" message="Loading editor..." />
</div>
</div>
)

View File

@ -1,5 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import FlowchartEditor from './FlowchartEditor'
import type { FlowchartData } from '@/types/flowchart'
@ -28,6 +27,7 @@ export default async function EditorPage({ params }: PageProps) {
.single()
if (error || !project) {
<<<<<<< HEAD
notFound()
}
@ -47,9 +47,15 @@ export default async function EditorPage({ params }: PageProps) {
<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">
<div className="flex items-center gap-4">
=======
return (
<div className="flex h-screen flex-col">
<header className="flex items-center border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
<Link
href="/dashboard"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
aria-label="Back to dashboard"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -64,10 +70,37 @@ export default async function EditorPage({ params }: PageProps) {
/>
</svg>
</Link>
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
{project.name}
</h1>
</header>
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
<div className="flex flex-col items-center gap-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&apos;re looking for doesn&apos;t exist or you don&apos;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>
<<<<<<< HEAD
</header>
<div className="flex-1">
@ -76,7 +109,22 @@ export default async function EditorPage({ params }: PageProps) {
initialData={flowchartData}
needsMigration={needsMigration}
/>
</div>
=======
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
</div>
)
}
const flowchartData = (project.flowchart_data || {
nodes: [],
edges: [],
}) as FlowchartData
return (
<FlowchartEditor
projectId={project.id}
projectName={project.name}
initialData={flowchartData}
/>
)
}

128
src/app/login/LoginForm.tsx Normal file
View File

@ -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>
)
}

View File

@ -1,130 +1,25 @@
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Suspense } from 'react'
import LoginForm from './LoginForm'
import PasswordResetModal from '@/components/PasswordResetModal'
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
// Check for success message from password reset
const message = searchParams.get('message')
const successMessage = message === 'password_reset_success'
? 'Your password has been reset successfully. Please sign in with your new password.'
: null
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoading(true)
const supabase = createClient()
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
router.push('/dashboard')
}
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<Suspense
fallback={
<div className="w-full max-w-md space-y-8 text-center">
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
WebVNWrite
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Sign in to your account
Loading...
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
{successMessage && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-700 dark:text-green-400">{successMessage}</p>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
}
>
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="you@example.com"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
</div>
<div className="flex items-center justify-end">
<Link
href="/forgot-password"
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
Forgot your password?
</Link>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
<LoginForm />
</Suspense>
<PasswordResetModal />
</div>
)
}

View File

@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from 'next/navigation'
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
redirect('/dashboard')
}

View File

@ -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>
)
}

View File

@ -1,238 +1,23 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Suspense } from 'react'
import SignupForm from './SignupForm'
export default function SignupPage() {
const router = useRouter()
const searchParams = useSearchParams()
// Pre-fill email if provided in URL (from invite link)
const [email, setEmail] = useState(searchParams.get('email') ?? '')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
// Handle invite/signup token from URL hash
// Supabase adds tokens to the URL hash after redirect
const handleTokenFromUrl = async () => {
const hash = window.location.hash
if (hash) {
const params = new URLSearchParams(hash.substring(1))
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
const type = params.get('type')
if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) {
const supabase = createClient()
const { error } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
})
if (error) {
setError('Invalid or expired invite link. Please request a new invitation.')
return
}
// Get the user's email from the session
const { data: { user } } = await supabase.auth.getUser()
if (user?.email) {
setEmail(user.email)
}
}
}
}
handleTokenFromUrl()
}, [searchParams])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
// Validate passwords match
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
// Validate password length
if (password.length < 6) {
setError('Password must be at least 6 characters')
return
}
setLoading(true)
const supabase = createClient()
// Check if user already has a session (from invite link)
const { data: { session } } = await supabase.auth.getSession()
if (session) {
// User was invited and has a session - update their password
const { error: updateError } = await supabase.auth.updateUser({
password,
})
if (updateError) {
setError(updateError.message)
setLoading(false)
return
}
// Create profile record
const { error: profileError } = await supabase.from('profiles').upsert({
id: session.user.id,
email: session.user.email,
display_name: session.user.email?.split('@')[0] || 'User',
is_admin: false,
})
if (profileError) {
setError(profileError.message)
setLoading(false)
return
}
router.push('/dashboard')
} else {
// Regular signup flow (if allowed)
const { data, error } = await supabase.auth.signUp({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
return
}
if (data.user) {
// Create profile record
const { error: profileError } = await supabase.from('profiles').upsert({
id: data.user.id,
email: data.user.email,
display_name: email.split('@')[0] || 'User',
is_admin: false,
})
if (profileError) {
setError(profileError.message)
setLoading(false)
return
}
router.push('/dashboard')
}
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<Suspense
fallback={
<div className="w-full max-w-md space-y-8 text-center">
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
WebVNWrite
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Complete your account setup
Loading...
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
}
>
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="you@example.com"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Confirm password
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
<p className="text-center text-sm text-zinc-600 dark:text-zinc-400">
Already have an account?{' '}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
Sign in
</Link>
</p>
</form>
</div>
<SignupForm />
</Suspense>
</div>
)
}

View File

@ -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>
)
}

View File

@ -29,6 +29,12 @@ export default function Navbar({ userEmail, isAdmin }: NavbarProps) {
Invite User
</Link>
)}
<Link
href="/dashboard/settings"
className="text-sm font-medium text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-200"
>
Settings
</Link>
<span className="text-sm text-zinc-600 dark:text-zinc-400">
{userEmail}
</span>

View File

@ -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>
)
}

View File

@ -6,16 +6,23 @@ interface ToastProps {
message: string
type: 'success' | 'error'
onClose: () => void
action?: {
label: string
onClick: () => void
}
}
export default function Toast({ message, type, onClose }: ToastProps) {
export default function Toast({ message, type, onClose, action }: ToastProps) {
useEffect(() => {
// Don't auto-dismiss if there's an action button
if (action) return
const timer = setTimeout(() => {
onClose()
}, 3000)
return () => clearTimeout(timer)
}, [onClose])
}, [onClose, action])
const bgColor =
type === 'success'
@ -60,6 +67,14 @@ export default function Toast({ message, type, onClose }: ToastProps) {
>
{icon}
<span>{message}</span>
{action && (
<button
onClick={action.onClick}
className="ml-2 rounded bg-white/20 px-2 py-0.5 text-xs font-semibold hover:bg-white/30"
>
{action.label}
</button>
)}
<button
onClick={onClose}
className="ml-2 rounded p-0.5 hover:bg-white/20"

View File

@ -1,13 +1,18 @@
'use client'
<<<<<<< HEAD
import { useCallback, useMemo, useState } from 'react'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
import { useEditorContext } from '@/components/editor/EditorContext'
=======
import { useState, useCallback, useEffect } from 'react'
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
import type { Condition } from '@/types/flowchart'
type ConditionEditorProps = {
edgeId: string
<<<<<<< HEAD
condition: Condition | undefined
onChange: (edgeId: string, condition: Condition | undefined) => void
onClose: () => void
@ -215,10 +220,133 @@ export default function ConditionEditor({
{hasInvalidReference && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Variable not found
=======
condition?: Condition
onSave: (edgeId: string, condition: Condition) => void
onRemove: (edgeId: string) => void
onCancel: () => void
}
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
export default function ConditionEditor({
edgeId,
condition,
onSave,
onRemove,
onCancel,
}: ConditionEditorProps) {
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
const [value, setValue] = useState(condition?.value ?? 0)
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onCancel])
const handleSave = useCallback(() => {
if (!variableName.trim()) return
onSave(edgeId, {
variableName: variableName.trim(),
operator,
value,
})
}, [edgeId, variableName, operator, value, onSave])
const handleRemove = useCallback(() => {
onRemove(edgeId)
}, [edgeId, onRemove])
const hasExistingCondition = !!condition
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 border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{hasExistingCondition ? 'Edit Condition' : 'Add Condition'}
</h3>
<div className="space-y-4">
{/* Variable Name Input */}
<div>
<label
htmlFor="variableName"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Variable Name
</label>
<input
type="text"
id="variableName"
value={variableName}
onChange={(e) => setVariableName(e.target.value)}
placeholder="e.g., score, health, affection"
autoFocus
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500"
/>
</div>
{/* Operator Dropdown */}
<div>
<label
htmlFor="operator"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Operator
</label>
<select
id="operator"
value={operator}
onChange={(e) => setOperator(e.target.value as Condition['operator'])}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
>
{OPERATORS.map((op) => (
<option key={op} value={op}>
{op === '==' ? '== (equals)' : op === '!=' ? '!= (not equals)' : op === '>' ? '> (greater than)' : op === '<' ? '< (less than)' : op === '>=' ? '>= (greater or equal)' : '<= (less or equal)'}
</option>
))}
</select>
</div>
{/* Value Number Input */}
<div>
<label
htmlFor="value"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Value
</label>
<input
type="number"
id="value"
value={value}
onChange={(e) => setValue(parseFloat(e.target.value) || 0)}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
/>
</div>
{/* Preview */}
{variableName.trim() && (
<div className="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-700">
<span className="text-sm text-zinc-600 dark:text-zinc-300">
Condition: <code className="font-mono text-blue-600 dark:text-blue-400">{variableName.trim()} {operator} {value}</code>
</span>
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
</div>
)}
</div>
<<<<<<< HEAD
{/* Inline add form */}
{showAddForm && (
<div className="mb-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
@ -315,6 +443,38 @@ export default function ConditionEditor({
>
Done
</button>
=======
{/* Action Buttons */}
<div className="mt-6 flex justify-between">
<div>
{hasExistingCondition && (
<button
type="button"
onClick={handleRemove}
className="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
>
Remove
</button>
)}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={!variableName.trim()}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Save
</button>
</div>
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
</div>
</div>
</div>

View File

@ -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>
)
}

View File

@ -1,5 +1,6 @@
'use client'
<<<<<<< HEAD
import { useCallback, useMemo, useState } from 'react'
import Combobox from '@/components/editor/Combobox'
import type { ComboboxItem } from '@/components/editor/Combobox'
@ -208,10 +209,143 @@ export default function OptionConditionEditor({
{hasInvalidReference && (
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
Variable not found
=======
import { useState, useCallback, useEffect } from 'react'
import type { Condition } from '@/types/flowchart'
type OptionConditionEditorProps = {
optionId: string
optionLabel: string
condition?: Condition
onSave: (optionId: string, condition: Condition) => void
onRemove: (optionId: string) => void
onCancel: () => void
}
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
export default function OptionConditionEditor({
optionId,
optionLabel,
condition,
onSave,
onRemove,
onCancel,
}: OptionConditionEditorProps) {
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
const [value, setValue] = useState(condition?.value ?? 0)
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onCancel])
const handleSave = useCallback(() => {
if (!variableName.trim()) return
onSave(optionId, {
variableName: variableName.trim(),
operator,
value,
})
}, [optionId, variableName, operator, value, onSave])
const handleRemove = useCallback(() => {
onRemove(optionId)
}, [optionId, onRemove])
const hasExistingCondition = !!condition
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 border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
onClick={(e) => e.stopPropagation()}
>
<h3 className="mb-1 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{hasExistingCondition ? 'Edit Option Condition' : 'Add Option Condition'}
</h3>
<p className="mb-4 text-sm text-zinc-500 dark:text-zinc-400">
Option: {optionLabel || '(unnamed)'}
</p>
<div className="space-y-4">
{/* Variable Name Input */}
<div>
<label
htmlFor="optionVariableName"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Variable Name
</label>
<input
type="text"
id="optionVariableName"
value={variableName}
onChange={(e) => setVariableName(e.target.value)}
placeholder="e.g., affection, score, health"
autoFocus
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500"
/>
</div>
{/* Operator Dropdown */}
<div>
<label
htmlFor="optionOperator"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Operator
</label>
<select
id="optionOperator"
value={operator}
onChange={(e) => setOperator(e.target.value as Condition['operator'])}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
>
{OPERATORS.map((op) => (
<option key={op} value={op}>
{op === '==' ? '== (equals)' : op === '!=' ? '!= (not equals)' : op === '>' ? '> (greater than)' : op === '<' ? '< (less than)' : op === '>=' ? '>= (greater or equal)' : '<= (less or equal)'}
</option>
))}
</select>
</div>
{/* Value Number Input */}
<div>
<label
htmlFor="optionValue"
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Value
</label>
<input
type="number"
id="optionValue"
value={value}
onChange={(e) => setValue(parseFloat(e.target.value) || 0)}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
/>
</div>
{/* Preview */}
{variableName.trim() && (
<div className="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-700">
<span className="text-sm text-zinc-600 dark:text-zinc-300">
Show option when: <code className="font-mono text-amber-600 dark:text-amber-400">{variableName.trim()} {operator} {value}</code>
</span>
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
</div>
)}
</div>
<<<<<<< HEAD
{/* Inline add form */}
{showAddForm && (
<div className="mb-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
@ -308,6 +442,38 @@ export default function OptionConditionEditor({
>
Done
</button>
=======
{/* Action Buttons */}
<div className="mt-6 flex justify-between">
<div>
{hasExistingCondition && (
<button
type="button"
onClick={handleRemove}
className="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
>
Remove
</button>
)}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={!variableName.trim()}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Save
</button>
</div>
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
</div>
</div>
</div>

View File

@ -6,8 +6,13 @@ type ToolbarProps = {
onAddVariable: () => void
onSave: () => void
onExport: () => void
onExportRenpy: () => void
onImport: () => void
<<<<<<< HEAD
onProjectSettings: () => void
=======
isSaving?: boolean
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
}
export default function Toolbar({
@ -16,8 +21,13 @@ export default function Toolbar({
onAddVariable,
onSave,
onExport,
onExportRenpy,
onImport,
<<<<<<< HEAD
onProjectSettings,
=======
isSaving = false,
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
}: ToolbarProps) {
return (
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
@ -54,9 +64,32 @@ export default function Toolbar({
</button>
<button
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
onClick={onExport}
@ -64,6 +97,12 @@ export default function Toolbar({
>
Export
</button>
<button
onClick={onExportRenpy}
className="rounded border border-purple-400 bg-purple-50 px-3 py-1.5 text-sm font-medium text-purple-700 hover:bg-purple-100 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:border-purple-500 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50 dark:focus:ring-offset-zinc-800"
>
Export to Ren&apos;Py
</button>
<button
onClick={onImport}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"

View File

@ -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>
)}
</>
)
}

View File

@ -1,11 +1,19 @@
'use client'
<<<<<<< HEAD
import { useCallback, useMemo, useState, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import { nanoid } from 'nanoid'
import { useEditorContext } from '@/components/editor/EditorContext'
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
import type { Condition } from '@/types/flowchart'
=======
import { useCallback, useState, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import { nanoid } from 'nanoid'
import type { Condition } from '@/types/flowchart'
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
type ChoiceOption = {
id: string
@ -23,7 +31,10 @@ const MAX_OPTIONS = 6
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
const { setNodes } = useReactFlow()
<<<<<<< HEAD
const { variables } = useEditorContext()
=======
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
const updatePrompt = useCallback(
@ -123,6 +134,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[id, data.options.length, setNodes]
)
<<<<<<< HEAD
const editingOption = useMemo(() => {
if (!editingConditionOptionId) return null
return data.options.find((opt) => opt.id === editingConditionOptionId) || null
@ -136,6 +148,59 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[variables]
)
=======
const handleSaveCondition = useCallback(
(optionId: string, condition: Condition) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) =>
opt.id === optionId ? { ...opt, condition } : opt
),
},
}
: node
)
)
setEditingConditionOptionId(null)
},
[id, setNodes]
)
const handleRemoveCondition = useCallback(
(optionId: string) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) => {
if (opt.id !== optionId) return opt
const updated = { ...opt }
delete updated.condition
return updated
}),
},
}
: node
)
)
setEditingConditionOptionId(null)
},
[id, setNodes]
)
const editingOption = editingConditionOptionId
? data.options.find((opt) => opt.id === editingConditionOptionId)
: null
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
return (
<>
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
@ -228,6 +293,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
</button>
</div>
<<<<<<< HEAD
{editingOption && (
<OptionConditionEditor
condition={editingOption.condition}
@ -236,5 +302,97 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
/>
)}
</>
=======
<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"
>
&times;
</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 && (
<OptionConditionEditor
optionId={editingOption.id}
optionLabel={editingOption.label}
condition={editingOption.condition}
onSave={handleSaveCondition}
onRemove={handleRemoveCondition}
onCancel={() => setEditingConditionOptionId(null)}
/>
)}
</div>
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
)
}

View File

@ -4,6 +4,7 @@ export type Position = {
y: number;
};
<<<<<<< HEAD
// Character type: represents a defined character in the project
export type Character = {
id: string;
@ -27,6 +28,13 @@ export type Condition = {
variableId?: string;
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
value: number | string | boolean;
=======
// Condition type for conditional edges and choice options
export type Condition = {
variableName: string;
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
value: number;
>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
};
// DialogueNode type: represents character speech/dialogue