Merge pull request 'ralph/vn-flowchart-editor' (#4) from ralph/vn-flowchart-editor into developing

Reviewed-on: #4
This commit is contained in:
GHMiranda 2026-01-23 14:34:47 +00:00
commit a6a966ce8f
22 changed files with 2829 additions and 524 deletions

View File

@ -533,7 +533,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 30,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -552,7 +552,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 31,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -568,7 +568,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 32,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -585,7 +585,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 33,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -603,7 +603,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 34,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -620,7 +620,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 35,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -638,7 +638,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 36,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -658,7 +658,7 @@
"Typecheck passes"
],
"priority": 37,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -675,7 +675,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 38,
"passes": false,
"passes": true,
"notes": ""
},
{
@ -692,8 +692,61 @@
"Verify in browser using dev-browser skill"
],
"priority": 39,
"passes": false,
"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"
}
]
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,5 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import FlowchartEditor from './FlowchartEditor'
import type { FlowchartData } from '@/types/flowchart'
@ -28,21 +27,13 @@ export default async function EditorPage({ params }: PageProps) {
.single()
if (error || !project) {
notFound()
}
const flowchartData = (project.flowchart_data || {
nodes: [],
edges: [],
}) as FlowchartData
return (
<div className="flex h-screen flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="flex items-center gap-4">
<header className="flex items-center border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
<Link
href="/dashboard"
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
aria-label="Back to dashboard"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -57,18 +48,50 @@ export default async function EditorPage({ params }: PageProps) {
/>
</svg>
</Link>
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
{project.name}
</h1>
</div>
</header>
<div className="flex-1">
<FlowchartEditor
projectId={project.id}
initialData={flowchartData}
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
<div className="flex flex-col items-center gap-4 text-center">
<svg
className="h-12 w-12 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Project Not Found
</h2>
<p className="max-w-sm text-sm text-zinc-600 dark:text-zinc-400">
The project you&apos;re looking for doesn&apos;t exist or you don&apos;t have access to it.
</p>
<Link
href="/dashboard"
className="mt-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
>
Back to Dashboard
</Link>
</div>
</div>
</div>
)
}
const flowchartData = (project.flowchart_data || {
nodes: [],
edges: [],
}) as FlowchartData
return (
<FlowchartEditor
projectId={project.id}
projectName={project.name}
initialData={flowchartData}
/>
)
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,40 @@
type LoadingSpinnerProps = {
size?: 'sm' | 'md' | 'lg'
message?: string
}
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
export default function LoadingSpinner({ size = 'md', message }: LoadingSpinnerProps) {
return (
<div className="flex flex-col items-center gap-3">
<svg
className={`animate-spin text-blue-500 ${sizeClasses[size]}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{message && (
<p className="text-sm text-zinc-600 dark:text-zinc-400">{message}</p>
)}
</div>
)
}

View File

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

View File

@ -0,0 +1,170 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
export default function PasswordResetModal() {
const router = useRouter()
const [isOpen, setIsOpen] = useState(false)
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [tokenError, setTokenError] = useState<string | null>(null)
useEffect(() => {
const handleTokenFromUrl = async () => {
const hash = window.location.hash
if (!hash) return
const params = new URLSearchParams(hash.substring(1))
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
const type = params.get('type')
if (accessToken && refreshToken && type === 'recovery') {
const supabase = createClient()
const { error } = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
})
if (error) {
setTokenError('Invalid or expired reset link. Please request a new password reset.')
return
}
// Clear hash from URL without reloading
window.history.replaceState(null, '', window.location.pathname + window.location.search)
setIsOpen(true)
}
}
handleTokenFromUrl()
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters')
return
}
setLoading(true)
const supabase = createClient()
const { error: updateError } = await supabase.auth.updateUser({
password,
})
if (updateError) {
setError(updateError.message)
setLoading(false)
return
}
await supabase.auth.signOut()
setIsOpen(false)
router.push('/login?message=password_reset_success')
}
if (tokenError) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Reset link expired
</h2>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{tokenError}
</p>
<button
onClick={() => setTokenError(null)}
className="mt-4 w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"
>
Close
</button>
</div>
</div>
)
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Set new password
</h2>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Enter your new password below.
</p>
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-3 dark:bg-red-900/20">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<div>
<label
htmlFor="reset-password"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
New password
</label>
<input
id="reset-password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
<div>
<label
htmlFor="reset-confirm-password"
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Confirm new password
</label>
<input
id="reset-confirm-password"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-800"
>
{loading ? 'Updating password...' : 'Update password'}
</button>
</form>
</div>
</div>
)
}

View File

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

View File

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

View File

@ -0,0 +1,131 @@
'use client'
import { useCallback, useEffect } from 'react'
export type ContextMenuType = 'canvas' | 'node' | 'edge'
type ContextMenuProps = {
x: number
y: number
type: ContextMenuType
onClose: () => void
onAddDialogue?: () => void
onAddChoice?: () => void
onAddVariable?: () => void
onDelete?: () => void
onAddCondition?: () => void
}
export default function ContextMenu({
x,
y,
type,
onClose,
onAddDialogue,
onAddChoice,
onAddVariable,
onDelete,
onAddCondition,
}: ContextMenuProps) {
// Close menu on Escape key
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
},
[onClose]
)
// Close menu on click outside
const handleClickOutside = useCallback(() => {
onClose()
}, [onClose])
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', handleClickOutside)
}
}, [handleKeyDown, handleClickOutside])
const menuItemClass =
'w-full px-4 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer'
return (
<div
className="fixed z-50 min-w-40 rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800"
style={{ left: x, top: y }}
onClick={(e) => e.stopPropagation()}
>
{type === 'canvas' && (
<>
<button
className={`${menuItemClass} text-blue-600 dark:text-blue-400`}
onClick={() => {
onAddDialogue?.()
onClose()
}}
>
Add Dialogue
</button>
<button
className={`${menuItemClass} text-green-600 dark:text-green-400`}
onClick={() => {
onAddChoice?.()
onClose()
}}
>
Add Choice
</button>
<button
className={`${menuItemClass} text-orange-600 dark:text-orange-400`}
onClick={() => {
onAddVariable?.()
onClose()
}}
>
Add Variable
</button>
</>
)}
{type === 'node' && (
<button
className={`${menuItemClass} text-red-600 dark:text-red-400`}
onClick={() => {
onDelete?.()
onClose()
}}
>
Delete
</button>
)}
{type === 'edge' && (
<>
<button
className={`${menuItemClass} text-red-600 dark:text-red-400`}
onClick={() => {
onDelete?.()
onClose()
}}
>
Delete
</button>
<button
className={menuItemClass}
onClick={() => {
onAddCondition?.()
onClose()
}}
>
Add Condition
</button>
</>
)}
</div>
)
}

View File

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

View File

@ -6,7 +6,9 @@ type ToolbarProps = {
onAddVariable: () => void
onSave: () => void
onExport: () => void
onExportRenpy: () => void
onImport: () => void
isSaving?: boolean
}
export default function Toolbar({
@ -15,7 +17,9 @@ export default function Toolbar({
onAddVariable,
onSave,
onExport,
onExportRenpy,
onImport,
isSaving = false,
}: ToolbarProps) {
return (
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
@ -46,9 +50,32 @@ export default function Toolbar({
<div className="flex items-center gap-2">
<button
onClick={onSave}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
disabled={isSaving}
className="flex items-center gap-1.5 rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
>
Save
{isSaving && (
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
onClick={onExport}
@ -56,6 +83,12 @@ export default function Toolbar({
>
Export
</button>
<button
onClick={onExportRenpy}
className="rounded border border-purple-400 bg-purple-50 px-3 py-1.5 text-sm font-medium text-purple-700 hover:bg-purple-100 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:border-purple-500 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50 dark:focus:ring-offset-zinc-800"
>
Export to Ren&apos;Py
</button>
<button
onClick={onImport}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"

View File

@ -0,0 +1,71 @@
'use client'
import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getSmoothStepPath,
} from 'reactflow'
import type { Condition } from '@/types/flowchart'
type ConditionalEdgeData = {
condition?: Condition
}
export default function ConditionalEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
selected,
}: EdgeProps<ConditionalEdgeData>) {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
const hasCondition = !!data?.condition
// Format condition as readable label
const conditionLabel = hasCondition
? `${data.condition!.variableName} ${data.condition!.operator} ${data.condition!.value}`
: null
return (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
strokeDasharray: hasCondition ? '5 5' : undefined,
stroke: selected ? '#3b82f6' : hasCondition ? '#f59e0b' : '#64748b',
strokeWidth: selected ? 2 : 1.5,
}}
/>
{conditionLabel && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
}}
className="rounded border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 dark:border-amber-600 dark:bg-amber-900 dark:text-amber-200"
>
{conditionLabel}
</div>
</EdgeLabelRenderer>
)}
</>
)
}

View File

@ -1,12 +1,15 @@
'use client'
import { useCallback, ChangeEvent } from 'react'
import { useCallback, useState, ChangeEvent } from 'react'
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
import { nanoid } from 'nanoid'
import type { Condition } from '@/types/flowchart'
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
type ChoiceOption = {
id: string
label: string
condition?: Condition
}
type ChoiceNodeData = {
@ -19,6 +22,7 @@ const MAX_OPTIONS = 6
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
const { setNodes } = useReactFlow()
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
const updatePrompt = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
@ -96,6 +100,57 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
[id, data.options.length, setNodes]
)
const handleSaveCondition = useCallback(
(optionId: string, condition: Condition) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) =>
opt.id === optionId ? { ...opt, condition } : opt
),
},
}
: node
)
)
setEditingConditionOptionId(null)
},
[id, setNodes]
)
const handleRemoveCondition = useCallback(
(optionId: string) => {
setNodes((nodes) =>
nodes.map((node) =>
node.id === id
? {
...node,
data: {
...node.data,
options: node.data.options.map((opt: ChoiceOption) => {
if (opt.id !== optionId) return opt
const updated = { ...opt }
delete updated.condition
return updated
}),
},
}
: node
)
)
setEditingConditionOptionId(null)
},
[id, setNodes]
)
const editingOption = editingConditionOptionId
? data.options.find((opt) => opt.id === editingConditionOptionId)
: null
return (
<div className="min-w-[220px] rounded-lg border-2 border-green-500 bg-green-50 p-3 shadow-md dark:border-green-400 dark:bg-green-950">
<Handle
@ -119,7 +174,8 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
<div className="space-y-2">
{data.options.map((option, index) => (
<div key={option.id} className="relative flex items-center gap-1">
<div key={option.id}>
<div className="relative flex items-center gap-1">
<input
type="text"
value={option.label}
@ -127,6 +183,26 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
placeholder={`Option ${index + 1}`}
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
/>
<button
type="button"
onClick={() => setEditingConditionOptionId(option.id)}
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
option.condition
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/50 dark:text-amber-300 dark:hover:bg-amber-900/70'
: 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:text-zinc-500 dark:hover:bg-zinc-700 dark:hover:text-zinc-300'
}`}
title={option.condition ? `Condition: ${option.condition.variableName} ${option.condition.operator} ${option.condition.value}` : 'Add condition'}
>
{option.condition ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
)}
</button>
<button
type="button"
onClick={() => removeOption(option.id)}
@ -134,7 +210,7 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
title="Remove option"
>
×
&times;
</button>
<Handle
type="source"
@ -146,6 +222,14 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
}}
/>
</div>
{option.condition && (
<div className="ml-1 mt-0.5 flex items-center gap-1">
<span className="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
if {option.condition.variableName} {option.condition.operator} {option.condition.value}
</span>
</div>
)}
</div>
))}
</div>
@ -158,6 +242,17 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
>
+ Add Option
</button>
{editingOption && (
<OptionConditionEditor
optionId={editingOption.id}
optionLabel={editingOption.label}
condition={editingOption.condition}
onSave={handleSaveCondition}
onRemove={handleRemoveCondition}
onCancel={() => setEditingConditionOptionId(null)}
/>
)}
</div>
)
}

View File

@ -4,6 +4,13 @@ export type Position = {
y: number;
};
// Condition type for conditional edges and choice options
export type Condition = {
variableName: string;
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
value: number;
};
// DialogueNode type: represents character speech/dialogue
export type DialogueNode = {
id: string;
@ -19,6 +26,7 @@ export type DialogueNode = {
export type ChoiceOption = {
id: string;
label: string;
condition?: Condition;
};
// ChoiceNode type: represents branching decisions
@ -47,13 +55,6 @@ export type VariableNode = {
// Union type for all node types
export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode;
// Condition type for conditional edges
export type Condition = {
variableName: string;
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
value: number;
};
// FlowchartEdge type: represents connections between nodes
export type FlowchartEdge = {
id: string;