developing #6
57
prd.json
57
prd.json
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
80
progress.txt
80
progress.txt
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -552,3 +555,80 @@
|
|||
- Validate JSON output with JSON.parse before download to ensure validity
|
||||
- Use purple color scheme for Ren'Py-specific button to distinguish from generic export
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-038
|
||||
- What was implemented: Unsaved changes warning with dirty state tracking, beforeunload, and navigation confirmation modal
|
||||
- Files changed:
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - added isDirty tracking via useMemo comparing current state to lastSavedDataRef, beforeunload event handler, navigation warning modal, back button with handleBackClick, moved header from page.tsx into this component
|
||||
- src/app/editor/[projectId]/page.tsx - simplified to only render FlowchartEditor (header moved to client component for dirty state access)
|
||||
- **Learnings for future iterations:**
|
||||
- Dirty state tracking uses useMemo comparing JSON.stringify of current flowchart data to a lastSavedDataRef
|
||||
- lastSavedDataRef is a useRef initialized with initialData and updated after successful save
|
||||
- Browser beforeunload requires both event.preventDefault() and setting event.returnValue = '' for modern browsers
|
||||
- Header with back navigation was moved from server component (page.tsx) to client component (FlowchartEditor.tsx) so it can access isDirty state
|
||||
- Back button uses handleBackClick which checks isDirty before navigating or showing confirmation modal
|
||||
- Navigation warning modal shows "Leave Page" (red) and "Stay" buttons for clear user action
|
||||
- "(unsaved changes)" indicator shown next to project name when isDirty is true
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-039
|
||||
- What was implemented: Loading and error states with reusable spinner, error page, and toast retry
|
||||
- Files changed:
|
||||
- src/components/LoadingSpinner.tsx - new reusable loading spinner component with size variants and optional message
|
||||
- src/app/editor/[projectId]/loading.tsx - updated to use LoadingSpinner component
|
||||
- src/app/editor/[projectId]/page.tsx - replaced notFound() with custom error UI showing "Project Not Found" with back to dashboard link
|
||||
- src/components/Toast.tsx - added optional action prop for action buttons (e.g., retry)
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated toast state type to include action, save error now shows retry button via handleSaveRef pattern
|
||||
- **Learnings for future iterations:**
|
||||
- Use a ref (handleSaveRef) to break circular dependency when a useCallback needs to reference itself for retry logic
|
||||
- Toast action prop uses `{ label: string; onClick: () => void }` for flexible action buttons
|
||||
- Don't auto-dismiss toasts that have action buttons (users need time to click them)
|
||||
- Replace `notFound()` with inline error UI when you need custom styling and navigation links
|
||||
- LoadingSpinner uses size prop ('sm' | 'md' | 'lg') for flexibility across different contexts
|
||||
- Link component from next/link is needed in server components for navigation (no useRouter in server components)
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-040
|
||||
- What was implemented: Conditionals on choice options - per-option visibility conditions
|
||||
- Files changed:
|
||||
- src/types/flowchart.ts - moved Condition type before ChoiceOption, added optional condition field to ChoiceOption
|
||||
- src/components/editor/nodes/ChoiceNode.tsx - added condition button per option, condition badge display, condition editing state management
|
||||
- src/components/editor/OptionConditionEditor.tsx - new modal component for editing per-option conditions (variable name, operator, value)
|
||||
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated Ren'Py export to include per-option conditions (option condition takes priority over edge condition)
|
||||
- **Learnings for future iterations:**
|
||||
- Per-option conditions use the same Condition type as edge conditions
|
||||
- Condition type needed to be moved above ChoiceOption in types file since ChoiceOption now references it
|
||||
- Use `delete obj.property` pattern instead of destructuring with unused variable to avoid lint warnings
|
||||
- OptionConditionEditor is separate from ConditionEditor because it operates on option IDs vs edge IDs
|
||||
- In Ren'Py export, option-level condition takes priority over edge condition since it represents visibility
|
||||
- Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not
|
||||
- Condition badge below option shows "if variableName operator value" text in compact format
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-041
|
||||
- What was implemented: Change password for logged-in user from settings page
|
||||
- Files changed:
|
||||
- src/app/dashboard/settings/page.tsx - new client component with password change form (current, new, confirm fields)
|
||||
- src/components/Navbar.tsx - added "Settings" link to navbar
|
||||
- **Learnings for future iterations:**
|
||||
- Settings page lives under /dashboard/settings to reuse the dashboard layout (navbar, auth check)
|
||||
- Re-authentication uses signInWithPassword with current password before allowing updateUser
|
||||
- Supabase getUser() returns current user email needed for re-auth signInWithPassword call
|
||||
- Password validation: check match and minimum length (6 chars) before making API calls
|
||||
- Clear form fields after successful password update for security
|
||||
- Settings link in navbar uses neutral zinc colors to distinguish from admin/action links
|
||||
---
|
||||
|
||||
## 2026-01-22 - US-042
|
||||
- What was implemented: Password reset modal that automatically appears when a recovery token is detected in the URL
|
||||
- Files changed:
|
||||
- src/components/PasswordResetModal.tsx - new client component with modal that detects recovery tokens from URL hash, sets session, and provides password reset form
|
||||
- src/app/login/page.tsx - integrated PasswordResetModal component on the login page
|
||||
- **Learnings for future iterations:**
|
||||
- PasswordResetModal is a standalone component that can be placed on any page to detect recovery tokens
|
||||
- Use window.history.replaceState to clean the URL hash after extracting the token (prevents re-triggering on refresh)
|
||||
- Separate tokenError state from form error state to show different UI (expired link vs. form validation)
|
||||
- Modal uses fixed positioning with z-50 to overlay above page content
|
||||
- After successful password update, sign out the user and redirect to login with success message (same as reset-password page)
|
||||
- The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const supabase = createClient()
|
||||
|
||||
// Re-authenticate with current password
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user?.email) {
|
||||
setError('Unable to verify current user.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: user.email,
|
||||
password: currentPassword,
|
||||
})
|
||||
|
||||
if (signInError) {
|
||||
setError('Current password is incorrect.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Update to new password
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password: newPassword,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess('Password updated successfully.')
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch {
|
||||
setError('An unexpected error occurred.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 mb-8">
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<div className="max-w-md">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50 mb-4">
|
||||
Change Password
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="currentPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||
>
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="newPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-zinc-900"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
|
|
@ -296,6 +297,11 @@ function convertToRenpyFormat(
|
|||
}
|
||||
}
|
||||
|
||||
// Per-option condition (visibility condition) takes priority over edge condition
|
||||
if (option.condition) {
|
||||
choice.condition = option.condition
|
||||
}
|
||||
|
||||
return choice
|
||||
})
|
||||
|
||||
|
|
@ -412,14 +418,21 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
|||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
||||
const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error'; action?: { label: string; onClick: () => void } } | null>(null)
|
||||
const [importConfirmDialog, setImportConfirmDialog] = useState<{
|
||||
pendingData: FlowchartData
|
||||
} | null>(null)
|
||||
const [showNavigationWarning, setShowNavigationWarning] = useState(false)
|
||||
|
||||
// Track the last saved data to determine dirty state
|
||||
const lastSavedDataRef = useRef<FlowchartData>(initialData)
|
||||
|
||||
// Ref for hidden file input
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Ref for save function to enable retry without circular dependency
|
||||
const handleSaveRef = useRef<() => void>(() => {})
|
||||
|
||||
// Check for saved draft on initial render (lazy initialization)
|
||||
const [draftState, setDraftState] = useState<{
|
||||
showPrompt: boolean
|
||||
|
|
@ -473,6 +486,33 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
|||
}
|
||||
}, [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) {
|
||||
|
|
@ -590,15 +630,25 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
|||
// 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. Please try again.', type: '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(() => {
|
||||
// Convert React Flow state to FlowchartData
|
||||
const flowchartData: FlowchartData = {
|
||||
|
|
@ -932,8 +982,63 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
|||
setConditionEditor(null)
|
||||
}, [])
|
||||
|
||||
// Router for navigation
|
||||
const router = useRouter()
|
||||
|
||||
// Handle back button click - show warning if dirty
|
||||
const handleBackClick = useCallback(() => {
|
||||
if (isDirty) {
|
||||
setShowNavigationWarning(true)
|
||||
} else {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}, [isDirty, router])
|
||||
|
||||
// Confirm navigation (discard unsaved changes)
|
||||
const handleConfirmNavigation = useCallback(() => {
|
||||
setShowNavigationWarning(false)
|
||||
router.push('/dashboard')
|
||||
}, [router])
|
||||
|
||||
// Cancel navigation
|
||||
const handleCancelNavigation = useCallback(() => {
|
||||
setShowNavigationWarning(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
{/* Editor header with back button and project name */}
|
||||
<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">
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
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"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{projectName}
|
||||
</h1>
|
||||
{isDirty && (
|
||||
<span className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
(unsaved changes)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Toolbar
|
||||
onAddDialogue={handleAddDialogue}
|
||||
onAddChoice={handleAddChoice}
|
||||
|
|
@ -1050,6 +1155,35 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation warning dialog */}
|
||||
{showNavigationWarning && (
|
||||
<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-900">
|
||||
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-white">
|
||||
Unsaved Changes
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
You have unsaved changes that will be lost if you leave this page.
|
||||
Are you sure you want to leave?
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleConfirmNavigation}
|
||||
className="flex-1 rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
|
||||
>
|
||||
Leave Page
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelNavigation}
|
||||
className="flex-1 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-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
>
|
||||
Stay
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hidden file input for import */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
@ -1065,6 +1199,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
|||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast(null)}
|
||||
action={toast.action}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex items-center border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<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,19 +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>
|
||||
</header>
|
||||
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<svg
|
||||
className="h-12 w-12 text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Project Not Found
|
||||
</h2>
|
||||
<p className="max-w-sm text-sm text-zinc-600 dark:text-zinc-400">
|
||||
The project you're looking for doesn't exist or you don't have access to it.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mt-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1">
|
||||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
initialData={flowchartData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const flowchartData = (project.flowchart_data || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}) as FlowchartData
|
||||
|
||||
return (
|
||||
<FlowchartEditor
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
initialData={flowchartData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Check for success message from password reset
|
||||
const message = searchParams.get('message')
|
||||
const successMessage = message === 'password_reset_success'
|
||||
? 'Your password has been reset successfully. Please sign in with your new password.'
|
||||
: null
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
WebVNWrite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{successMessage && (
|
||||
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
|
||||
<p className="text-sm text-green-700 dark:text-green-400">{successMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,130 +1,25 @@
|
|||
'use client'
|
||||
|
||||
import { 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">
|
||||
<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>
|
||||
<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">
|
||||
Loading...
|
||||
</p>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,236 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function SignupForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
// Pre-fill email if provided in URL (from invite link)
|
||||
const [email, setEmail] = useState(searchParams.get('email') ?? '')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Handle invite/signup token from URL hash
|
||||
// Supabase adds tokens to the URL hash after redirect
|
||||
const handleTokenFromUrl = async () => {
|
||||
const hash = window.location.hash
|
||||
if (hash) {
|
||||
const params = new URLSearchParams(hash.substring(1))
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const type = params.get('type')
|
||||
|
||||
if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError('Invalid or expired invite link. Please request a new invitation.')
|
||||
return
|
||||
}
|
||||
|
||||
// Get the user's email from the session
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user?.email) {
|
||||
setEmail(user.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTokenFromUrl()
|
||||
}, [searchParams])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
// Check if user already has a session (from invite link)
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
if (session) {
|
||||
// User was invited and has a session - update their password
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Create profile record
|
||||
const { error: profileError } = await supabase.from('profiles').upsert({
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
display_name: session.user.email?.split('@')[0] || 'User',
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
// Regular signup flow (if allowed)
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
// Create profile record
|
||||
const { error: profileError } = await supabase.from('profiles').upsert({
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
display_name: email.split('@')[0] || 'User',
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
WebVNWrite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Complete your account setup
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,238 +1,23 @@
|
|||
'use client'
|
||||
|
||||
import { 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">
|
||||
<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>
|
||||
<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">
|
||||
Loading...
|
||||
</p>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
type LoadingSpinnerProps = {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
message?: string
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({ size = 'md', message }: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<svg
|
||||
className={`animate-spin text-blue-500 ${sizeClasses[size]}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{message && (
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -29,6 +29,12 @@ export default function Navbar({ userEmail, isAdmin }: NavbarProps) {
|
|||
Invite User
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function PasswordResetModal() {
|
||||
const router = useRouter()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tokenError, setTokenError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleTokenFromUrl = async () => {
|
||||
const hash = window.location.hash
|
||||
if (!hash) return
|
||||
|
||||
const params = new URLSearchParams(hash.substring(1))
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const type = params.get('type')
|
||||
|
||||
if (accessToken && refreshToken && type === 'recovery') {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setTokenError('Invalid or expired reset link. Please request a new password reset.')
|
||||
return
|
||||
}
|
||||
|
||||
// Clear hash from URL without reloading
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.search)
|
||||
setIsOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
handleTokenFromUrl()
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
await supabase.auth.signOut()
|
||||
|
||||
setIsOpen(false)
|
||||
router.push('/login?message=password_reset_success')
|
||||
}
|
||||
|
||||
if (tokenError) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Reset link expired
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{tokenError}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setTokenError(null)}
|
||||
className="mt-4 w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Set new password
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-3 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="reset-password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="reset-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="reset-confirm-password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="reset-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{loading ? 'Updating password...' : 'Update password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,16 +6,23 @@ interface ToastProps {
|
|||
message: string
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,32 +174,61 @@ 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">
|
||||
<input
|
||||
type="text"
|
||||
value={option.label}
|
||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(option.id)}
|
||||
disabled={data.options.length <= MIN_OPTIONS}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
||||
title="Remove option"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={`option-${index}`}
|
||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||
style={{
|
||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div key={option.id}>
|
||||
<div className="relative flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={option.label}
|
||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingConditionOptionId(option.id)}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
||||
option.condition
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/50 dark:text-amber-300 dark:hover:bg-amber-900/70'
|
||||
: 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:text-zinc-500 dark:hover:bg-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
title={option.condition ? `Condition: ${option.condition.variableName} ${option.condition.operator} ${option.condition.value}` : 'Add condition'}
|
||||
>
|
||||
{option.condition ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(option.id)}
|
||||
disabled={data.options.length <= MIN_OPTIONS}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
||||
title="Remove option"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={`option-${index}`}
|
||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||
style={{
|
||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{option.condition && (
|
||||
<div className="ml-1 mt-0.5 flex items-center gap-1">
|
||||
<span className="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
if {option.condition.variableName} {option.condition.operator} {option.condition.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue