diff --git a/progress.txt b/progress.txt index 132f596..023908d 100644 --- a/progress.txt +++ b/progress.txt @@ -201,102 +201,198 @@ - No browser testing tools are available; manual verification is needed. --- -## 2026-01-23 - US-064 -- What was implemented: Export validation that scans nodes/edges for undefined character/variable references before exporting, with a warning modal and canvas highlighting +## 2026-01-22 - US-030 +- What was implemented: Right-click context menu for canvas, nodes, and edges - Files changed: - - `src/components/editor/ExportValidationModal.tsx` - New component: warning modal listing validation issues by type (character/variable), showing node type badge and content snippet, with "Export anyway" and "Cancel" buttons - - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Updated `handleExport` to scan nodes (dialogue, variable, choice) and edges for characterId/variableId references not matching defined entries; added `performExport`, `handleExportAnyway`, `handleExportCancel` callbacks; added `validationIssues` and `warningNodeIds` state; applied `export-warning-node` className to affected nodes via `styledNodes` memo - - `src/app/globals.css` - Added `.react-flow__node.export-warning-node` CSS with orange outline and pulse animation for highlighted nodes + - 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:** - - React Flow nodes support a `className` prop that gets applied to the `.react-flow__node` wrapper div, so custom styling can be applied via CSS without modifying node component internals. - - The validation scans four sources of references: dialogue nodes (characterId), variable nodes (variableId), choice node option conditions (variableId), and edge conditions (variableId). - - The `handleExport` is the validation gate; `performExport` is where actual export logic (US-035) will go. When no issues are found, `performExport` is called directly. Otherwise the modal is shown and the user decides. - - Warning highlighting is cleared either by canceling the modal or by proceeding with "Export anyway". - - Pre-existing lint issues in `ConditionEditor.tsx` and `OptionConditionEditor.tsx` (React Compiler `preserve-manual-memoization` errors) are from prior stories and not related to this change. - - No browser testing tools are available; manual verification is needed. + - 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-23 - US-043 -- What was implemented: Database migration adding project_collaborators, collaboration_sessions, and audit_trail tables with RLS policies and indexes +## 2026-01-22 - US-031 +- What was implemented: Condition editor modal for adding/editing/removing conditions on edges - Files changed: - - `supabase/migrations/20260123100000_add_collaboration_and_audit_trail.sql` - New migration with three tables, RLS policies, indexes, and updated projects RLS for collaborator access + - src/components/editor/ConditionEditor.tsx - new modal component with form for variable name, operator, and value + - src/app/editor/[projectId]/FlowchartEditor.tsx - integrated condition editor with double-click and context menu triggers - **Learnings for future iterations:** - - `project_collaborators` has a UNIQUE constraint on (project_id, user_id) to prevent duplicate invitations - - RLS policies for collaboration tables use subqueries to check either project ownership (via `projects.user_id`) or collaboration membership (via `project_collaborators.user_id`) - - The audit_trail insert policy requires both `auth.uid() = user_id` AND project access (owner or editor role) to prevent unauthorized audit writes - - New RLS policies were added to the existing `projects` table to allow collaborators to SELECT and UPDATE (editors/owners only) shared projects - - The audit_trail index uses `created_at DESC` for efficient reverse-chronological pagination in the history sidebar - - `collaboration_sessions.cursor_position` is JSONB to store flexible coordinate data (x, y, and potentially viewport info) - - `collaboration_sessions.selected_node_id` is nullable text since a user may not have any node selected + - 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-23 - US-045 -- What was implemented: Supabase Realtime channel and connection management with connection lifecycle, heartbeat, auto-reconnect, and toolbar status indicator +## 2026-01-22 - US-032 +- What was implemented: Display conditions on edges with dashed styling and labels - Files changed: - - `src/lib/collaboration/realtime.ts` - New module: `RealtimeConnection` class with connect/disconnect, heartbeat (30s interval), exponential backoff reconnect, session upsert/delete, presence sync callback - - `src/components/editor/Toolbar.tsx` - Added `connectionState` optional prop with color-coded indicator (green=connected, yellow=connecting/reconnecting, red=disconnected) - - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `userId` prop, imported `RealtimeConnection`, added `connectionState` state + `useEffect` for connection lifecycle, passed `connectionState` to Toolbar - - `src/app/editor/[projectId]/page.tsx` - Passed `userId={user.id}` to FlowchartEditor component - - `supabase/migrations/20260123200000_add_collaboration_sessions_unique_constraint.sql` - Added UNIQUE(project_id, user_id) constraint for upsert support + - 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:** - - Supabase Realtime channel subscription statuses are: `SUBSCRIBED`, `CHANNEL_ERROR`, `TIMED_OUT`, `CLOSED`. Handle each for connection state tracking. - - The `RealtimeConnection` class creates its own Supabase client instance via `createClient()` since the browser client is stateless and cheap to create - - `useRef` is used to hold the connection instance across renders without triggering re-renders. The `useEffect` cleanup calls `disconnect()` to properly clean up. - - The Supabase channel name is `project:{projectId}` — future stories (presence, CRDT) should join the same channel via `realtimeRef.current.getChannel()` - - The `collaboration_sessions` table needed a UNIQUE constraint on (project_id, user_id) for the upsert pattern to work; this was added as a separate migration - - Pre-existing lint errors in ConditionEditor.tsx and OptionConditionEditor.tsx (React Compiler `preserve-manual-memoization`) are unrelated to this story - - No browser testing tools are available; manual verification is needed. + - 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-23 - US-044 -- What was implemented: Project sharing and collaborator management with Share button, modal, server actions, collaborator access to editor, and dashboard shared projects section +## 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]/actions.ts` - New server actions module: `getCollaborators`, `inviteCollaborator`, `updateCollaboratorRole`, `removeCollaborator` with ownership verification and profile lookups - - `src/components/editor/ShareModal.tsx` - New modal component: invite form (email + role), collaborator list with role change and remove actions, error/success messaging - - `src/components/editor/Toolbar.tsx` - Added `onShare` prop and "Share" button between "Project Settings" and "Save" - - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `isOwner` prop, `showShare` state, ShareModal rendering, passed `onShare` to Toolbar - - `src/app/editor/[projectId]/page.tsx` - Updated project fetching to support collaborator access (owner-first lookup, fallback to project_collaborators check), pass `isOwner` prop - - `src/app/dashboard/page.tsx` - Added query for shared projects via `project_collaborators` join, rendered "Shared with me" section with ProjectCard - - `src/components/ProjectCard.tsx` - Made `onDelete`/`onRename` optional, added `shared`/`sharedRole` props, conditionally shows role badge instead of edit/delete actions for shared projects + - src/app/editor/[projectId]/FlowchartEditor.tsx - added LocalStorage auto-save functionality, draft check on load, and restoration prompt UI - **Learnings for future iterations:** - - Supabase join queries with `profiles(display_name, email)` return the type as an array due to generated types. Cast through `unknown` to the expected shape: `c.profiles as unknown as { display_name: string | null; email: string | null } | null` - - The RLS policies from US-043 migration already handle collaborator access to projects (SELECT and UPDATE for editors). No new migration was needed for this story. - - For editor page.tsx, checking collaborator access requires two queries: first try `.eq('user_id', user.id)` for ownership, then check `project_collaborators` table. RLS on projects allows collaborators to SELECT, so the second `.from('projects')` query works. - - Server actions in App Router can be co-located with page.tsx in the same route directory (e.g., `src/app/editor/[projectId]/actions.ts`) - - The `accepted_at` field is auto-set on invite (auto-accept pattern). A pending invitation flow would require leaving `accepted_at` null and adding an accept action. - - No browser testing tools are available; manual verification is needed. + - 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-23 - US-046 -- What was implemented: Presence indicators showing connected collaborators as avatar circles in the editor toolbar +## 2026-01-22 - US-034 +- What was implemented: Save project to database functionality - Files changed: - - `src/lib/collaboration/realtime.ts` - Added `PresenceUser` type, `displayName` constructor param, presence tracking via `channel.track()`, presence sync parsing that excludes own user - - `src/components/editor/PresenceAvatars.tsx` - New component: avatar circles with user initials, consistent color from user ID hash, tooltip with display_name, max 5 visible with "+N" overflow - - `src/components/editor/Toolbar.tsx` - Added `presenceUsers` prop and `PresenceAvatars` rendering before connection indicator - - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `userDisplayName` prop, `presenceUsers` state, updated `RealtimeConnection` instantiation with `displayName` and `onPresenceSync` callback - - `src/app/editor/[projectId]/page.tsx` - Fetches user's `display_name` from profiles table and passes as `userDisplayName` prop + - 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:** - - Supabase Realtime presence uses `channel.track({ ...data })` after subscription to broadcast user info. The presence key is set in channel config: `config: { presence: { key: userId } }`. - - The `presenceState()` returns a `Record` where each key maps to an array of presence objects. Filter out own key to exclude self. - - Consistent user colors are generated via a simple hash of the userId string, indexed into a fixed palette of 12 colors. This ensures the same user always gets the same color across sessions. - - The `PresenceAvatars` component shows initials (first letter of first two words, or first two chars for single-word names) with the computed background color. - - Pre-existing lint errors in ConditionEditor.tsx, OptionConditionEditor.tsx, and ShareModal.tsx are from prior stories and unrelated to this change. - - No browser testing tools are available; manual verification is needed. + - 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-23 - US-048 -- What was implemented: Yjs CRDT integration for conflict-free node/edge synchronization across multiple collaborators +## 2026-01-22 - US-035 +- What was implemented: Export project as .vnflow file functionality - Files changed: - - `package.json` / `package-lock.json` - Added `yjs` dependency - - `src/lib/collaboration/crdt.ts` - New module: `CRDTManager` class wrapping Y.Doc with Y.Map for nodes (keyed by node ID) and Y.Map for edges (keyed by edge ID), broadcast via Supabase Realtime channel, debounced persistence (2s) - - `src/lib/collaboration/realtime.ts` - Added `onChannelSubscribed` callback to `RealtimeCallbacks` type, invoked after channel subscription to allow CRDT connection - - `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added CRDTManager initialization from DB data, channel connection via `onChannelSubscribed`, bidirectional sync (local→CRDT via useEffect on nodes/edges, remote→local via callbacks), Supabase persistence on debounced timer + - 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:** - - Yjs updates are serialized as `Uint8Array`. For Supabase Realtime broadcast (which uses JSON), convert to `Array.from(update)` for sending and `new Uint8Array(data)` for receiving. - - The CRDT uses an `isApplyingRemote` flag to prevent echo loops: when applying remote updates, observers skip re-notifying React state (since the state setter is what triggers the change). Similarly, `isRemoteUpdateRef` in FlowchartEditor prevents local→CRDT sync when the change originated from a remote update. - - Y.Map stores serialized JSON strings (not objects) as values to avoid Yjs's nested type complexity. This means each node/edge is independently merge-able at the entry level. - - The `onPersist` callback captures `characters` and `variables` from the effect closure — this means the persisted data always uses the character/variable state at the time the effect was created. For full correctness in a production system, these should be passed dynamically. - - The CRDT document is initialized once from `migratedData` (the lazy-computed initial state). The `initializeFromData` call uses an 'init' origin to distinguish initialization from local edits. - - The `onChannelSubscribed` callback pattern allows the CRDT to connect to the channel only after it's fully subscribed, avoiding race conditions with broadcast messages arriving before the listener is set up. - - Pre-existing lint errors in ConditionEditor.tsx, OptionConditionEditor.tsx, and ShareModal.tsx are from prior stories and unrelated to this change. + - 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 d9b0b9e..78a4d89 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -14,11 +14,16 @@ 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' @@ -35,9 +40,7 @@ import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable, type FlowchartEditorProps = { projectId: string - userId: string - userDisplayName: string - isOwner: boolean + projectName: string initialData: FlowchartData needsMigration?: boolean } @@ -61,7 +64,7 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] { target: edge.target, targetHandle: edge.targetHandle, data: edge.data, - type: 'smoothstep', + type: 'conditional', markerEnd: { type: MarkerType.ArrowClosed, }, @@ -209,7 +212,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) { } // Inner component that uses useReactFlow hook -function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, initialData, needsMigration }: FlowchartEditorProps) { +function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) { // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ @@ -220,7 +223,49 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini [] ) - const { getViewport } = useReactFlow() + // Define custom edge types - memoized to prevent re-renders + const edgeTypes: EdgeTypes = useMemo( + () => ({ + conditional: ConditionalEdge, + }), + [] + ) + + const { getViewport, screenToFlowPosition } = useReactFlow() + + const [contextMenu, setContextMenu] = useState(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)) @@ -387,6 +432,78 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini [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 @@ -396,7 +513,7 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini target: params.target, sourceHandle: params.sourceHandle, targetHandle: params.targetHandle, - type: 'smoothstep', + type: 'conditional', markerEnd: { type: MarkerType.ArrowClosed, }, @@ -460,100 +577,205 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini setNodes((nodes) => [...nodes, newNode]) }, [getViewportCenter, setNodes]) - const handleSave = useCallback(() => { - // TODO: Implement in US-034 - }, []) + const handleSave = useCallback(async () => { + if (isSaving) return - const performExport = useCallback(() => { - // TODO: Actual export logic in US-035 - setValidationIssues(null) - setWarningNodeIds(new Set()) - }, []) + 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(() => { - const issues: ValidationIssue[] = [] - const characterIds = new Set(characters.map((c) => c.id)) - const variableIds = new Set(variables.map((v) => v.id)) - - // Scan nodes for undefined references - nodes.forEach((node) => { - if (node.type === 'dialogue' && node.data?.characterId) { - if (!characterIds.has(node.data.characterId)) { - issues.push({ - nodeId: node.id, - nodeType: 'dialogue', - contentSnippet: node.data.text - ? `"${node.data.text.slice(0, 40)}${node.data.text.length > 40 ? '...' : ''}"` - : 'Empty dialogue', - undefinedReference: node.data.characterId, - referenceType: 'character', - }) - } - } - if (node.type === 'variable' && node.data?.variableId) { - if (!variableIds.has(node.data.variableId)) { - issues.push({ - nodeId: node.id, - nodeType: 'variable', - contentSnippet: node.data.variableName - ? `Variable: ${node.data.variableName}` - : 'Variable node', - undefinedReference: node.data.variableId, - referenceType: 'variable', - }) - } - } - if (node.type === 'choice' && node.data?.options) { - node.data.options.forEach((opt: { id: string; label: string; condition?: { variableId?: string; variableName?: string } }) => { - if (opt.condition?.variableId && !variableIds.has(opt.condition.variableId)) { - issues.push({ - nodeId: node.id, - nodeType: 'choice', - contentSnippet: opt.label - ? `Option: "${opt.label.slice(0, 30)}${opt.label.length > 30 ? '...' : ''}"` - : 'Choice option', - undefinedReference: opt.condition.variableId, - referenceType: 'variable', - }) - } - }) - } - }) - - // Scan edges for undefined variable references in conditions - edges.forEach((edge) => { - if (edge.data?.condition?.variableId && !variableIds.has(edge.data.condition.variableId)) { - issues.push({ - nodeId: edge.id, - nodeType: 'edge', - contentSnippet: edge.data.condition.variableName - ? `Condition on: ${edge.data.condition.variableName}` - : 'Edge condition', - undefinedReference: edge.data.condition.variableId, - referenceType: 'variable', - }) - } - }) - - if (issues.length === 0) { - performExport() - } else { - setValidationIssues(issues) - setWarningNodeIds(new Set(issues.map((i) => i.nodeId))) + // Convert React Flow state to FlowchartData + const flowchartData: FlowchartData = { + nodes: fromReactFlowNodes(nodes), + edges: fromReactFlowEdges(edges), } - }, [nodes, edges, characters, variables, performExport]) - const handleExportAnyway = useCallback(() => { - performExport() - }, [performExport]) + // Create pretty-printed JSON + const jsonContent = JSON.stringify(flowchartData, null, 2) - const handleExportCancel = useCallback(() => { - setValidationIssues(null) - setWarningNodeIds(new Set()) + // 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) 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 bd9bc4f..c5511e8 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' @@ -80,13 +79,14 @@ export default async function EditorPage({ params }: PageProps) { // the project was created before these features existed and may need auto-migration const needsMigration = !rawData.characters && !rawData.variables - return ( + return (<>
-

- {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 + +
@@ -117,6 +143,20 @@ export default async function EditorPage({ params }: PageProps) { needsMigration={needsMigration} />
- + + ) + } + + 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 && ( + + )} + + + + )} + + {type === 'node' && ( + + )} + + {type === 'edge' && ( + <> + + + + )} + + ) +} diff --git a/src/components/editor/Toolbar.tsx b/src/components/editor/Toolbar.tsx index 67d9345..24fa6e7 100644 --- a/src/components/editor/Toolbar.tsx +++ b/src/components/editor/Toolbar.tsx @@ -9,6 +9,7 @@ type ToolbarProps = { onAddVariable: () => void onSave: () => void onExport: () => void + onExportRenpy: () => void onImport: () => void onProjectSettings: () => void onShare: () => void @@ -36,6 +37,7 @@ export default function Toolbar({ onAddVariable, onSave, onExport, + onExportRenpy, onImport, onProjectSettings, onShare, @@ -96,9 +98,32 @@ export default function Toolbar({ + + + +
+ {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 && ( updateOptionCondition(editingOption.id, condition)} - onClose={() => setEditingConditionOptionId(null)} + onSave={handleSaveCondition} + onRemove={handleRemoveCondition} + onCancel={() => setEditingConditionOptionId(null)} /> )} - + ) }