Merge pull request 'ralph/vn-flowchart-editor' (#2) from ralph/vn-flowchart-editor into master

Reviewed-on: #2
This commit is contained in:
GHMiranda 2026-01-23 07:53:28 +00:00
commit ac45f14942
18 changed files with 1416 additions and 505 deletions

View File

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

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

View File

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

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">
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&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>
</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}
/>
)
}

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

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

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,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

@ -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"
>
&times;
</button>
<Handle
type="source"
position={Position.Bottom}
id={`option-${index}`}
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
style={{
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
}}
/>
</div>
{option.condition && (
<div className="ml-1 mt-0.5 flex items-center gap-1">
<span className="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
if {option.condition.variableName} {option.condition.operator} {option.condition.value}
</span>
</div>
)}
</div>
))}
</div>
@ -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;