diff --git a/prd.json b/prd.json index 7a27321..6992f32 100644 --- a/prd.json +++ b/prd.json @@ -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 } ] } diff --git a/progress.txt b/progress.txt index 1c5272e..ad33e12 100644 --- a/progress.txt +++ b/progress.txt @@ -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 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 `` 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 +--- diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..23898f1 --- /dev/null +++ b/src/app/dashboard/settings/page.tsx @@ -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 ( +
+

+ Settings +

+ +
+

+ Change Password +

+ +
+ {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+
+
+ ) +} diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index f468ed6..acd5243 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,11 @@ 'use client' +<<<<<<< HEAD import React, { useCallback, useMemo, useState } from 'react' +======= +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411 import ReactFlow, { Background, BackgroundVariant, @@ -14,22 +19,54 @@ import ReactFlow, { Node, Edge, NodeTypes, + EdgeTypes, MarkerType, + NodeMouseHandler, + EdgeMouseHandler, } from 'reactflow' import { nanoid } from 'nanoid' import 'reactflow/dist/style.css' 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 ChoiceNode from '@/components/editor/nodes/ChoiceNode' import VariableNode from '@/components/editor/nodes/VariableNode' +<<<<<<< HEAD import ProjectSettingsModal from '@/components/editor/ProjectSettingsModal' import ConditionEditor from '@/components/editor/ConditionEditor' import { EditorProvider } from '@/components/editor/EditorContext' import Toast from '@/components/Toast' import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, Condition } from '@/types/flowchart' +======= +import ConditionalEdge from '@/components/editor/edges/ConditionalEdge' +import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' +import ConditionEditor from '@/components/editor/ConditionEditor' +import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart' + +// LocalStorage key prefix for draft saves +const DRAFT_KEY_PREFIX = 'vnwrite-draft-' + +// Debounce delay in ms +const AUTOSAVE_DEBOUNCE_MS = 1000 + +type ContextMenuState = { + x: number + y: number + type: ContextMenuType + nodeId?: string + edgeId?: string +} | null + +type ConditionEditorState = { + edgeId: string + condition?: Condition +} | null +>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411 type FlowchartEditorProps = { projectId: string + projectName: string initialData: FlowchartData needsMigration?: boolean } @@ -53,13 +90,14 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] { target: edge.target, targetHandle: edge.targetHandle, data: edge.data, - type: 'smoothstep', + type: 'conditional', markerEnd: { type: MarkerType.ArrowClosed, }, })) } +<<<<<<< HEAD const RANDOM_COLORS = [ '#EF4444', '#F97316', '#F59E0B', '#10B981', '#3B82F6', '#8B5CF6', '#EC4899', '#14B8A6', @@ -197,11 +235,323 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) { nodes: migratedNodes, edges: migratedEdges, toastMessage: `Auto-imported ${parts.join(' and ')} from existing data`, +======= +// Convert React Flow Node type back to our FlowchartNode type +function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] { + return nodes.map((node) => ({ + id: node.id, + type: node.type as 'dialogue' | 'choice' | 'variable', + position: node.position, + data: node.data, + })) as FlowchartNode[] +} + +// Convert React Flow Edge type back to our FlowchartEdge type +function fromReactFlowEdges(edges: Edge[]): FlowchartEdge[] { + return edges.map((edge) => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle ?? undefined, + target: edge.target, + targetHandle: edge.targetHandle ?? undefined, + data: edge.data, + })) +} + +// Get LocalStorage key for a project +function getDraftKey(projectId: string): string { + return `${DRAFT_KEY_PREFIX}${projectId}` +} + +// Save draft to LocalStorage +function saveDraft(projectId: string, data: FlowchartData): void { + try { + localStorage.setItem(getDraftKey(projectId), JSON.stringify(data)) + } catch (error) { + console.error('Failed to save draft to LocalStorage:', error) + } +} + +// Load draft from LocalStorage +function loadDraft(projectId: string): FlowchartData | null { + try { + const draft = localStorage.getItem(getDraftKey(projectId)) + if (!draft) return null + return JSON.parse(draft) as FlowchartData + } catch (error) { + console.error('Failed to load draft from LocalStorage:', error) + return null + } +} + +// Clear draft from LocalStorage +export function clearDraft(projectId: string): void { + try { + localStorage.removeItem(getDraftKey(projectId)) + } catch (error) { + console.error('Failed to clear draft from LocalStorage:', error) + } +} + +// Compare two FlowchartData objects for equality +function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean { + return JSON.stringify(a) === JSON.stringify(b) +} + +// Validate imported flowchart data structure +function isValidFlowchartData(data: unknown): data is FlowchartData { + if (!data || typeof data !== 'object') return false + const obj = data as Record + if (!Array.isArray(obj.nodes)) return false + if (!Array.isArray(obj.edges)) return false + return true +} + +// Ren'Py export types +type RenpyDialogueNode = { + type: 'dialogue' + speaker: string + text: string + next?: string + condition?: Condition +} + +type RenpyMenuChoice = { + label: string + next?: string + condition?: Condition +} + +type RenpyMenuNode = { + type: 'menu' + prompt: string + choices: RenpyMenuChoice[] +} + +type RenpyVariableNode = { + type: 'variable' + name: string + operation: 'set' | 'add' | 'subtract' + value: number + next?: string + condition?: Condition +} + +type RenpyNode = RenpyDialogueNode | RenpyMenuNode | RenpyVariableNode + +type RenpyExport = { + projectName: string + exportedAt: string + sections: Record +} + +// Find the first node (node with no incoming edges) +function findFirstNode(nodes: FlowchartNode[], edges: FlowchartEdge[]): FlowchartNode | null { + const targetIds = new Set(edges.map((e) => e.target)) + const startNodes = nodes.filter((n) => !targetIds.has(n.id)) + // Return the first start node, or the first node if all have incoming edges + return startNodes[0] || nodes[0] || null +} + +// Get outgoing edge from a node (for dialogue and variable nodes) +function getOutgoingEdge(nodeId: string, edges: FlowchartEdge[], sourceHandle?: string): FlowchartEdge | undefined { + return edges.find((e) => e.source === nodeId && (sourceHandle === undefined || e.sourceHandle === sourceHandle)) +} + +// Get all outgoing edges from a node (for choice nodes) +function getOutgoingEdges(nodeId: string, edges: FlowchartEdge[]): FlowchartEdge[] { + return edges.filter((e) => e.source === nodeId) +} + +// Convert flowchart to Ren'Py format using graph traversal +function convertToRenpyFormat( + nodes: FlowchartNode[], + edges: FlowchartEdge[], + projectName: string +): RenpyExport { + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + const visited = new Set() + const sections: Record = {} + let currentSectionName = 'start' + let currentSection: RenpyNode[] = [] + + // Helper to get or create a label for a node + const nodeLabels = new Map() + let labelCounter = 0 + + function getNodeLabel(nodeId: string): string { + if (!nodeLabels.has(nodeId)) { + const node = nodeMap.get(nodeId) + if (node?.type === 'dialogue' && node.data.speaker) { + // Use speaker name as part of label if available + nodeLabels.set(nodeId, `section_${node.data.speaker.toLowerCase().replace(/\s+/g, '_')}_${labelCounter++}`) + } else { + nodeLabels.set(nodeId, `section_${labelCounter++}`) + } + } + return nodeLabels.get(nodeId)! + } + + // Process a node and its successors + function processNode(nodeId: string): void { + if (visited.has(nodeId)) return + visited.add(nodeId) + + const node = nodeMap.get(nodeId) + if (!node) return + + if (node.type === 'dialogue') { + const outgoingEdge = getOutgoingEdge(nodeId, edges) + const renpyNode: RenpyDialogueNode = { + type: 'dialogue', + speaker: node.data.speaker || '', + text: node.data.text, + } + + if (outgoingEdge) { + // Check if target node is already visited (creates a jump) + if (visited.has(outgoingEdge.target)) { + renpyNode.next = getNodeLabel(outgoingEdge.target) + } else { + renpyNode.next = outgoingEdge.target + } + if (outgoingEdge.data?.condition) { + renpyNode.condition = outgoingEdge.data.condition + } + } + + currentSection.push(renpyNode) + + // Process next node if not visited + if (outgoingEdge && !visited.has(outgoingEdge.target)) { + processNode(outgoingEdge.target) + } + } else if (node.type === 'choice') { + const outgoingEdges = getOutgoingEdges(nodeId, edges) + + // Map options to their corresponding edges + const choices: RenpyMenuChoice[] = node.data.options.map((option, index) => { + // Find edge for this option handle + const optionEdge = outgoingEdges.find((e) => e.sourceHandle === `option-${index}`) + const choice: RenpyMenuChoice = { + label: option.label || `Option ${index + 1}`, + } + + if (optionEdge) { + // If target is visited, use label; otherwise use target id + if (visited.has(optionEdge.target)) { + choice.next = getNodeLabel(optionEdge.target) + } else { + choice.next = optionEdge.target + } + if (optionEdge.data?.condition) { + choice.condition = optionEdge.data.condition + } + } + + // Per-option condition (visibility condition) takes priority over edge condition + if (option.condition) { + choice.condition = option.condition + } + + return choice + }) + + const renpyNode: RenpyMenuNode = { + type: 'menu', + prompt: node.data.prompt || '', + choices, + } + + currentSection.push(renpyNode) + + // Save current section before processing branches + sections[currentSectionName] = currentSection + + // Process each branch in a new section + for (const choice of choices) { + if (choice.next && !visited.has(choice.next)) { + const targetNode = nodeMap.get(choice.next) + if (targetNode) { + // Start new section for this branch + currentSectionName = getNodeLabel(choice.next) + currentSection = [] + processNode(choice.next) + if (currentSection.length > 0) { + sections[currentSectionName] = currentSection + } + } + } + } + } else if (node.type === 'variable') { + const outgoingEdge = getOutgoingEdge(nodeId, edges) + const renpyNode: RenpyVariableNode = { + type: 'variable', + name: node.data.variableName, + operation: node.data.operation, + value: node.data.value, + } + + if (outgoingEdge) { + if (visited.has(outgoingEdge.target)) { + renpyNode.next = getNodeLabel(outgoingEdge.target) + } else { + renpyNode.next = outgoingEdge.target + } + if (outgoingEdge.data?.condition) { + renpyNode.condition = outgoingEdge.data.condition + } + } + + currentSection.push(renpyNode) + + if (outgoingEdge && !visited.has(outgoingEdge.target)) { + processNode(outgoingEdge.target) + } + } + } + + // Find and process starting from the first node + const firstNode = findFirstNode(nodes, edges) + if (firstNode) { + processNode(firstNode.id) + // Save the final section if it has content + if (currentSection.length > 0 && !sections[currentSectionName]) { + sections[currentSectionName] = currentSection + } + } + + // Replace node IDs in next fields with proper labels + for (const sectionNodes of Object.values(sections)) { + for (const renpyNode of sectionNodes) { + if (renpyNode.type === 'dialogue' || renpyNode.type === 'variable') { + if (renpyNode.next && nodeLabels.has(renpyNode.next)) { + renpyNode.next = nodeLabels.get(renpyNode.next) + } + } else if (renpyNode.type === 'menu') { + for (const choice of renpyNode.choices) { + if (choice.next && nodeLabels.has(choice.next)) { + choice.next = nodeLabels.get(choice.next) + } + } + } + } + } + + return { + projectName, + exportedAt: new Date().toISOString(), + sections, +>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411 } } // Inner component that uses useReactFlow hook +<<<<<<< HEAD function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) { +======= +function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) { +>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411 // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ @@ -212,7 +562,49 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch [] ) - 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(null) + const [conditionEditor, setConditionEditor] = useState(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(initialData) + + // Ref for hidden file input + const fileInputRef = useRef(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 const [migratedData] = useState(() => computeMigration(initialData, !!needsMigration)) @@ -283,6 +675,78 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch [nodes, edges] ) + // Track debounce timer + const saveTimerRef = useRef(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( (params: Connection) => { if (!params.source || !params.target) return @@ -292,7 +756,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch target: params.target, sourceHandle: params.sourceHandle, targetHandle: params.targetHandle, - type: 'smoothstep', + type: 'conditional', markerEnd: { type: MarkerType.ArrowClosed, }, @@ -356,16 +820,205 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch setNodes((nodes) => [...nodes, newNode]) }, [getViewportCenter, setNodes]) - const handleSave = useCallback(() => { - // TODO: Implement in US-034 - }, []) + const handleSave = useCallback(async () => { + if (isSaving) return + + setIsSaving(true) + + try { + 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(() => { - // TODO: Implement in US-035 + // Convert React Flow state to FlowchartData + const flowchartData: FlowchartData = { + nodes: fromReactFlowNodes(nodes), + edges: fromReactFlowEdges(edges), + } + + // Create pretty-printed JSON + const jsonContent = JSON.stringify(flowchartData, null, 2) + + // 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}.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) => { + 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(() => { - // TODO: Implement in US-036 + // Confirm import (discard unsaved changes) + 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) @@ -375,6 +1028,7 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch console.log('Deleted edges:', deletedEdges.map((e) => e.id)) }, []) +<<<<<<< HEAD // Handle edge click to open condition editor const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => { setSelectedEdgeId(edge.id) @@ -390,10 +1044,160 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch : edge ) ) +======= + // Context menu handlers + const closeContextMenu = useCallback(() => { + setContextMenu(null) + }, []) + + // Handle right-click on canvas (pane) + const onPaneContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault() + setContextMenu({ + x: event.clientX, + y: event.clientY, + type: 'canvas', + }) + }, + [] + ) + + // Handle right-click on node + const onNodeContextMenu: NodeMouseHandler = useCallback( + (event, node) => { + event.preventDefault() + setContextMenu({ + x: event.clientX, + y: event.clientY, + type: 'node', + nodeId: node.id, + }) + }, + [] + ) + + // Handle right-click on edge + const onEdgeContextMenu: EdgeMouseHandler = useCallback( + (event, edge) => { + event.preventDefault() + setContextMenu({ + x: event.clientX, + y: event.clientY, + type: 'edge', + edgeId: edge.id, + }) + }, + [] + ) + + // Add node at specific position (for context menu) + const handleAddNodeAtPosition = useCallback( + (type: 'dialogue' | 'choice' | 'variable') => { + if (!contextMenu) return + + // Convert screen position to flow position + const position = screenToFlowPosition({ + x: contextMenu.x, + y: contextMenu.y, + }) + + let newNode: Node + + if (type === 'dialogue') { + newNode = { + id: nanoid(), + type: 'dialogue', + position, + data: { speaker: '', text: '' }, + } + } else if (type === 'choice') { + newNode = { + id: nanoid(), + type: 'choice', + position, + data: { + prompt: '', + options: [ + { id: nanoid(), label: '' }, + { id: nanoid(), label: '' }, + ], + }, + } + } else { + newNode = { + id: nanoid(), + type: 'variable', + position, + data: { + variableName: '', + operation: 'set', + value: 0, + }, + } + } + + setNodes((nodes) => [...nodes, newNode]) + }, + [contextMenu, screenToFlowPosition, setNodes] + ) + + // Delete selected node from context menu + const handleDeleteNode = useCallback(() => { + if (!contextMenu?.nodeId) return + setNodes((nodes) => nodes.filter((n) => n.id !== contextMenu.nodeId)) + }, [contextMenu, setNodes]) + + // Delete selected edge from context menu + const handleDeleteEdge = useCallback(() => { + if (!contextMenu?.edgeId) return + setEdges((edges) => edges.filter((e) => e.id !== contextMenu.edgeId)) + }, [contextMenu, setEdges]) + + // Open condition editor for an edge + const openConditionEditor = useCallback( + (edgeId: string) => { + const edge = edges.find((e) => e.id === edgeId) + if (!edge) return + setConditionEditor({ + edgeId, + condition: edge.data?.condition, + }) + }, + [edges] + ) + + // Add condition to edge (opens ConditionEditor modal) + const handleAddCondition = useCallback(() => { + if (!contextMenu?.edgeId) return + openConditionEditor(contextMenu.edgeId) + }, [contextMenu, openConditionEditor]) + + // Handle double-click on edge to open condition editor + const onEdgeDoubleClick = useCallback( + (_event: React.MouseEvent, edge: Edge) => { + openConditionEditor(edge.id) + }, + [openConditionEditor] + ) + + // Save condition to edge + const handleSaveCondition = useCallback( + (edgeId: string, condition: Condition) => { + setEdges((eds) => + eds.map((edge) => + edge.id === edgeId + ? { ...edge, data: { ...edge.data, condition } } + : edge + ) + ) + setConditionEditor(null) +>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411 }, [setEdges] ) +<<<<<<< HEAD // Get the selected edge's condition data const selectedEdge = useMemo( () => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : null), @@ -458,6 +1262,251 @@ function FlowchartEditorInner({ projectId, initialData, needsMigration }: Flowch )} +======= + // Remove condition from edge + const handleRemoveCondition = useCallback( + (edgeId: string) => { + setEdges((eds) => + eds.map((edge) => { + if (edge.id !== edgeId) return edge + // Remove condition from data + const newData = { ...edge.data } + delete newData.condition + return { ...edge, data: newData } + }) + ) + setConditionEditor(null) + }, + [setEdges] + ) + + // Close condition editor + const closeConditionEditor = useCallback(() => { + setConditionEditor(null) + }, []) + + // Router for navigation + const router = useRouter() + + // Handle back button click - show warning if dirty + const handleBackClick = useCallback(() => { + if (isDirty) { + setShowNavigationWarning(true) + } else { + router.push('/dashboard') + } + }, [isDirty, router]) + + // Confirm navigation (discard unsaved changes) + const handleConfirmNavigation = useCallback(() => { + setShowNavigationWarning(false) + router.push('/dashboard') + }, [router]) + + // Cancel navigation + const handleCancelNavigation = useCallback(() => { + setShowNavigationWarning(false) + }, []) + + return ( +
+ {/* Editor header with back button and project name */} +
+
+ +

+ {projectName} +

+ {isDirty && ( + + (unsaved changes) + + )} +
+
+ + +
+ + + + +
+ + {contextMenu && ( + handleAddNodeAtPosition('dialogue')} + onAddChoice={() => handleAddNodeAtPosition('choice')} + onAddVariable={() => handleAddNodeAtPosition('variable')} + onDelete={ + contextMenu.type === 'node' ? handleDeleteNode : handleDeleteEdge + } + onAddCondition={handleAddCondition} + /> + )} + + {conditionEditor && ( + + )} + + {/* Draft restoration prompt */} + {draftState.showPrompt && ( +
+
+

+ Unsaved Draft Found +

+

+ A local draft was found that differs from the saved version. Would + you like to restore it or discard it? +

+
+ + +
+
+
+ )} + + {/* Import confirmation dialog */} + {importConfirmDialog && ( +
+
+

+ Unsaved Changes +

+

+ You have unsaved changes. Importing a new file will discard your + current work. Are you sure you want to continue? +

+
+ + +
+
+
+ )} + + {/* Navigation warning dialog */} + {showNavigationWarning && ( +
+
+

+ Unsaved Changes +

+

+ You have unsaved changes that will be lost if you leave this page. + Are you sure you want to leave? +

+
+ + +
+
+
+ )} + + {/* Hidden file input for import */} + + + {/* Toast notification */} + {toast && ( + setToast(null)} + action={toast.action} + /> + )} +
+>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411 ) } diff --git a/src/app/editor/[projectId]/loading.tsx b/src/app/editor/[projectId]/loading.tsx index 9ec9c55..231ffce 100644 --- a/src/app/editor/[projectId]/loading.tsx +++ b/src/app/editor/[projectId]/loading.tsx @@ -1,3 +1,5 @@ +import LoadingSpinner from '@/components/LoadingSpinner' + export default function EditorLoading() { return (
@@ -9,31 +11,7 @@ export default function EditorLoading() {
-
- - - - -

- Loading editor... -

-
+
) diff --git a/src/app/editor/[projectId]/page.tsx b/src/app/editor/[projectId]/page.tsx index 7c5f64a..0e50870 100644 --- a/src/app/editor/[projectId]/page.tsx +++ b/src/app/editor/[projectId]/page.tsx @@ -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) {
+======= + return ( +
+
+>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411 -

- {project.name} -

+
+
+
+ + + +

+ Project Not Found +

+

+ The project you're looking for doesn't exist or you don't have access to it. +

+ + Back to Dashboard + +
+<<<<<<< HEAD
@@ -76,7 +109,22 @@ export default async function EditorPage({ params }: PageProps) { initialData={flowchartData} needsMigration={needsMigration} /> +======= +>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
-
+ ) + } + + const flowchartData = (project.flowchart_data || { + nodes: [], + edges: [], + }) as FlowchartData + + return ( + ) } diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx new file mode 100644 index 0000000..29663df --- /dev/null +++ b/src/app/login/LoginForm.tsx @@ -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(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 ( +
+
+

+ WebVNWrite +

+

+ Sign in to your account +

+
+ +
+ {successMessage && ( +
+

{successMessage}

+
+ )} + + {error && ( +
+

{error}

+
+ )} + +
+
+ + 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" + /> +
+ +
+ + 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="••••••••" + /> +
+
+ +
+ + Forgot your password? + +
+ + +
+
+ ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 6a67346..231b62a 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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(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 (
-
-
-

- WebVNWrite -

-

- Sign in to your account -

-
- -
- {successMessage && ( -
-

{successMessage}

-
- )} - - {error && ( -
-

{error}

-
- )} - -
-
- - 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" - /> -
- -
- - 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="••••••••" - /> -
+ +

+ WebVNWrite +

+

+ Loading... +

- -
- - Forgot your password? - -
- - -
-
+ } + > + + +
) } diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..28c5ca1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { redirect } from 'next/navigation' export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); + redirect('/dashboard') } diff --git a/src/app/signup/SignupForm.tsx b/src/app/signup/SignupForm.tsx new file mode 100644 index 0000000..e839439 --- /dev/null +++ b/src/app/signup/SignupForm.tsx @@ -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(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 ( +
+
+

+ WebVNWrite +

+

+ Complete your account setup +

+
+ +
+ {error && ( +
+

{error}

+
+ )} + +
+
+ + 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" + /> +
+ +
+ + 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="••••••••" + /> +
+ +
+ + 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="••••••••" + /> +
+
+ + + +

+ Already have an account?{' '} + + Sign in + +

+
+
+ ) +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index fb1a733..de79472 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -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(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 (
-
-
-

- WebVNWrite -

-

- Complete your account setup -

-
- -
- {error && ( -
-

{error}

-
- )} - -
-
- - 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" - /> -
- -
- - 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="••••••••" - /> -
- -
- - 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="••••••••" - /> -
+ +

+ WebVNWrite +

+

+ Loading... +

- - - -

- Already have an account?{' '} - - Sign in - -

-
-
+ } + > + +
) } diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..bc2e451 --- /dev/null +++ b/src/components/LoadingSpinner.tsx @@ -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 ( +
+ + + + + {message && ( +

{message}

+ )} +
+ ) +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index d7d1a57..1e7f032 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -29,6 +29,12 @@ export default function Navbar({ userEmail, isAdmin }: NavbarProps) { Invite User )} + + Settings + {userEmail} diff --git a/src/components/PasswordResetModal.tsx b/src/components/PasswordResetModal.tsx new file mode 100644 index 0000000..9a190f7 --- /dev/null +++ b/src/components/PasswordResetModal.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [tokenError, setTokenError] = useState(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 ( +
+
+

+ Reset link expired +

+

+ {tokenError} +

+ +
+
+ ) + } + + if (!isOpen) return null + + return ( +
+
+

+ Set new password +

+

+ Enter your new password below. +

+ +
+ {error && ( +
+

{error}

+
+ )} + +
+ + 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="••••••••" + /> +
+ +
+ + 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="••••••••" + /> +
+ + +
+
+
+ ) +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 18ef18a..52b9687 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -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} {message} + {action && ( + + )} +======= + {/* Action Buttons */} +
+
+ {hasExistingCondition && ( + + )} +
+
+ + +
+>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
diff --git a/src/components/editor/ContextMenu.tsx b/src/components/editor/ContextMenu.tsx new file mode 100644 index 0000000..cb9ba53 --- /dev/null +++ b/src/components/editor/ContextMenu.tsx @@ -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 ( +
e.stopPropagation()} + > + {type === 'canvas' && ( + <> + + + + + )} + + {type === 'node' && ( + + )} + + {type === 'edge' && ( + <> + + + + )} +
+ ) +} diff --git a/src/components/editor/OptionConditionEditor.tsx b/src/components/editor/OptionConditionEditor.tsx index b96b5c3..57dc990 100644 --- a/src/components/editor/OptionConditionEditor.tsx +++ b/src/components/editor/OptionConditionEditor.tsx @@ -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 && (
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 ?? '==') + 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 ( +
+
e.stopPropagation()} + > +

+ {hasExistingCondition ? 'Edit Option Condition' : 'Add Option Condition'} +

+

+ Option: {optionLabel || '(unnamed)'} +

+ +
+ {/* Variable Name Input */} +
+ + 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" + /> +
+ + {/* Operator Dropdown */} +
+ + +
+ + {/* Value Number Input */} +
+ + 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" + /> +
+ + {/* Preview */} + {variableName.trim() && ( +
+ + Show option when: {variableName.trim()} {operator} {value} + +>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
)}
+<<<<<<< HEAD {/* Inline add form */} {showAddForm && (
@@ -308,6 +442,38 @@ export default function OptionConditionEditor({ > Done +======= + {/* Action Buttons */} +
+
+ {hasExistingCondition && ( + + )} +
+
+ + +
+>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411
diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 81c2c45..c01a979 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -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 (
@@ -54,9 +64,32 @@ export default function Toolbar({ +
+<<<<<<< HEAD {editingOption && ( ) { /> )} +======= + + +
+ {data.options.map((option, index) => ( +
+
+ 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" + /> + + + +
+ {option.condition && ( +
+ + if {option.condition.variableName} {option.condition.operator} {option.condition.value} + +
+ )} +
+ ))} +
+ + + + {editingOption && ( + setEditingConditionOptionId(null)} + /> + )} +
+>>>>>>> a6a966ce8f445a7ff2c20d92afd65214567eb411 ) } diff --git a/src/types/flowchart.ts b/src/types/flowchart.ts index 4f4461c..837ba1a 100644 --- a/src/types/flowchart.ts +++ b/src/types/flowchart.ts @@ -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