Merge branch 'developing' into ralph/collaboration-and-character-variables

This commit is contained in:
Gustavo Henrique Santos Souza de Miranda
2026-01-23 15:59:17 -03:00
18 changed files with 1649 additions and 623 deletions
+161
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>
)
}
+316 -94
View File
@@ -1,6 +1,6 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import ReactFlow, {
Background,
BackgroundVariant,
@@ -14,11 +14,16 @@ import ReactFlow, {
Node,
Edge,
NodeTypes,
EdgeTypes,
MarkerType,
NodeMouseHandler,
EdgeMouseHandler,
} from 'reactflow'
import { nanoid } from 'nanoid'
import 'reactflow/dist/style.css'
import Toolbar from '@/components/editor/Toolbar'
import Toast from '@/components/Toast'
import { createClient } from '@/lib/supabase/client'
import DialogueNode from '@/components/editor/nodes/DialogueNode'
import ChoiceNode from '@/components/editor/nodes/ChoiceNode'
import VariableNode from '@/components/editor/nodes/VariableNode'
@@ -35,9 +40,7 @@ import type { FlowchartData, FlowchartNode, FlowchartEdge, Character, Variable,
type FlowchartEditorProps = {
projectId: string
userId: string
userDisplayName: string
isOwner: boolean
projectName: string
initialData: FlowchartData
needsMigration?: boolean
}
@@ -61,7 +64,7 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] {
target: edge.target,
targetHandle: edge.targetHandle,
data: edge.data,
type: 'smoothstep',
type: 'conditional',
markerEnd: {
type: MarkerType.ArrowClosed,
},
@@ -209,7 +212,7 @@ function computeMigration(initialData: FlowchartData, shouldMigrate: boolean) {
}
// Inner component that uses useReactFlow hook
function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, initialData, needsMigration }: FlowchartEditorProps) {
function FlowchartEditorInner({ projectId, initialData, needsMigration }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders
const nodeTypes: NodeTypes = useMemo(
() => ({
@@ -220,7 +223,49 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini
[]
)
const { getViewport } = useReactFlow()
// Define custom edge types - memoized to prevent re-renders
const edgeTypes: EdgeTypes = useMemo(
() => ({
conditional: ConditionalEdge,
}),
[]
)
const { getViewport, screenToFlowPosition } = useReactFlow()
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(null)
const [isSaving, setIsSaving] = useState(false)
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error'; action?: { label: string; onClick: () => void } } | null>(null)
const [importConfirmDialog, setImportConfirmDialog] = useState<{
pendingData: FlowchartData
} | null>(null)
const [showNavigationWarning, setShowNavigationWarning] = useState(false)
// Track the last saved data to determine dirty state
const lastSavedDataRef = useRef<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
savedDraft: FlowchartData | null
}>(() => {
// This runs only once on initial render (client-side)
if (typeof window === 'undefined') {
return { showPrompt: false, savedDraft: null }
}
const draft = loadDraft(projectId)
if (draft && !flowchartDataEquals(draft, initialData)) {
return { showPrompt: true, savedDraft: draft }
}
return { showPrompt: false, savedDraft: null }
})
// Compute migrated data once on first render using a lazy state initializer
const [migratedData] = useState(() => computeMigration(initialData, !!needsMigration))
@@ -387,6 +432,78 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini
[nodes, edges]
)
// Track debounce timer
const saveTimerRef = useRef<NodeJS.Timeout | null>(null)
// Debounced auto-save to LocalStorage
useEffect(() => {
// Don't save while draft prompt is showing
if (draftState.showPrompt) return
// Clear existing timer
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
// Set new timer
saveTimerRef.current = setTimeout(() => {
const currentData: FlowchartData = {
nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges),
}
saveDraft(projectId, currentData)
}, AUTOSAVE_DEBOUNCE_MS)
// Cleanup on unmount
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
}
}, [nodes, edges, projectId, draftState.showPrompt])
// Calculate dirty state by comparing current data with last saved data
const isDirty = useMemo(() => {
const currentData: FlowchartData = {
nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges),
}
return !flowchartDataEquals(currentData, lastSavedDataRef.current)
}, [nodes, edges])
// Browser beforeunload warning when dirty
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isDirty) {
event.preventDefault()
// Modern browsers require returnValue to be set
event.returnValue = ''
return ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
}, [isDirty])
// Handle restoring draft
const handleRestoreDraft = useCallback(() => {
if (draftState.savedDraft) {
setNodes(toReactFlowNodes(draftState.savedDraft.nodes))
setEdges(toReactFlowEdges(draftState.savedDraft.edges))
}
setDraftState({ showPrompt: false, savedDraft: null })
}, [draftState.savedDraft, setNodes, setEdges])
// Handle discarding draft
const handleDiscardDraft = useCallback(() => {
clearDraft(projectId)
setDraftState({ showPrompt: false, savedDraft: null })
}, [projectId])
const onConnect = useCallback(
(params: Connection) => {
if (!params.source || !params.target) return
@@ -396,7 +513,7 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini
target: params.target,
sourceHandle: params.sourceHandle,
targetHandle: params.targetHandle,
type: 'smoothstep',
type: 'conditional',
markerEnd: {
type: MarkerType.ArrowClosed,
},
@@ -460,100 +577,205 @@ function FlowchartEditorInner({ projectId, userId, userDisplayName, isOwner, ini
setNodes((nodes) => [...nodes, newNode])
}, [getViewportCenter, setNodes])
const handleSave = useCallback(() => {
// TODO: Implement in US-034
}, [])
const handleSave = useCallback(async () => {
if (isSaving) return
const performExport = useCallback(() => {
// TODO: Actual export logic in US-035
setValidationIssues(null)
setWarningNodeIds(new Set())
}, [])
setIsSaving(true)
try {
const supabase = createClient()
// Convert React Flow state to FlowchartData
const flowchartData: FlowchartData = {
nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges),
}
const { error } = await supabase
.from('projects')
.update({
flowchart_data: flowchartData,
updated_at: new Date().toISOString(),
})
.eq('id', projectId)
if (error) {
throw error
}
// Clear LocalStorage draft after successful save
clearDraft(projectId)
// Update last saved data ref to mark as not dirty
lastSavedDataRef.current = flowchartData
setToast({ message: 'Project saved successfully', type: 'success' })
} catch (error) {
console.error('Failed to save project:', error)
setToast({
message: 'Failed to save project.',
type: 'error',
action: { label: 'Retry', onClick: () => { setToast(null); handleSaveRef.current() } },
})
} finally {
setIsSaving(false)
}
}, [isSaving, nodes, edges, projectId])
// Keep ref updated with latest handleSave
handleSaveRef.current = handleSave
const handleExport = useCallback(() => {
const issues: ValidationIssue[] = []
const characterIds = new Set(characters.map((c) => c.id))
const variableIds = new Set(variables.map((v) => v.id))
// Scan nodes for undefined references
nodes.forEach((node) => {
if (node.type === 'dialogue' && node.data?.characterId) {
if (!characterIds.has(node.data.characterId)) {
issues.push({
nodeId: node.id,
nodeType: 'dialogue',
contentSnippet: node.data.text
? `"${node.data.text.slice(0, 40)}${node.data.text.length > 40 ? '...' : ''}"`
: 'Empty dialogue',
undefinedReference: node.data.characterId,
referenceType: 'character',
})
}
}
if (node.type === 'variable' && node.data?.variableId) {
if (!variableIds.has(node.data.variableId)) {
issues.push({
nodeId: node.id,
nodeType: 'variable',
contentSnippet: node.data.variableName
? `Variable: ${node.data.variableName}`
: 'Variable node',
undefinedReference: node.data.variableId,
referenceType: 'variable',
})
}
}
if (node.type === 'choice' && node.data?.options) {
node.data.options.forEach((opt: { id: string; label: string; condition?: { variableId?: string; variableName?: string } }) => {
if (opt.condition?.variableId && !variableIds.has(opt.condition.variableId)) {
issues.push({
nodeId: node.id,
nodeType: 'choice',
contentSnippet: opt.label
? `Option: "${opt.label.slice(0, 30)}${opt.label.length > 30 ? '...' : ''}"`
: 'Choice option',
undefinedReference: opt.condition.variableId,
referenceType: 'variable',
})
}
})
}
})
// Scan edges for undefined variable references in conditions
edges.forEach((edge) => {
if (edge.data?.condition?.variableId && !variableIds.has(edge.data.condition.variableId)) {
issues.push({
nodeId: edge.id,
nodeType: 'edge',
contentSnippet: edge.data.condition.variableName
? `Condition on: ${edge.data.condition.variableName}`
: 'Edge condition',
undefinedReference: edge.data.condition.variableId,
referenceType: 'variable',
})
}
})
if (issues.length === 0) {
performExport()
} else {
setValidationIssues(issues)
setWarningNodeIds(new Set(issues.map((i) => i.nodeId)))
// Convert React Flow state to FlowchartData
const flowchartData: FlowchartData = {
nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges),
}
}, [nodes, edges, characters, variables, performExport])
const handleExportAnyway = useCallback(() => {
performExport()
}, [performExport])
// Create pretty-printed JSON
const jsonContent = JSON.stringify(flowchartData, null, 2)
const handleExportCancel = useCallback(() => {
setValidationIssues(null)
setWarningNodeIds(new Set())
// Create blob with JSON content
const blob = new Blob([jsonContent], { type: 'application/json' })
// Create download URL
const url = URL.createObjectURL(blob)
// Create temporary link element and trigger download
const link = document.createElement('a')
link.href = url
link.download = `${projectName}.vnflow`
document.body.appendChild(link)
link.click()
// Cleanup
document.body.removeChild(link)
URL.revokeObjectURL(url)
}, [nodes, edges, projectName])
const handleExportRenpy = useCallback(() => {
// Convert React Flow state to our flowchart types
const flowchartNodes = fromReactFlowNodes(nodes)
const flowchartEdges = fromReactFlowEdges(edges)
// Convert to Ren'Py format
const renpyExport = convertToRenpyFormat(flowchartNodes, flowchartEdges, projectName)
// Create pretty-printed JSON
const jsonContent = JSON.stringify(renpyExport, null, 2)
// Verify JSON is valid
try {
JSON.parse(jsonContent)
} catch {
setToast({ message: 'Failed to generate valid JSON', type: 'error' })
return
}
// Create blob with JSON content
const blob = new Blob([jsonContent], { type: 'application/json' })
// Create download URL
const url = URL.createObjectURL(blob)
// Create temporary link element and trigger download
const link = document.createElement('a')
link.href = url
link.download = `${projectName}-renpy.json`
document.body.appendChild(link)
link.click()
// Cleanup
document.body.removeChild(link)
URL.revokeObjectURL(url)
setToast({ message: 'Exported to Ren\'Py format successfully', type: 'success' })
}, [nodes, edges, projectName])
// Check if current flowchart has unsaved changes
const hasUnsavedChanges = useCallback(() => {
const currentData: FlowchartData = {
nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges),
}
return !flowchartDataEquals(currentData, initialData)
}, [nodes, edges, initialData])
// Load imported data into React Flow
const loadImportedData = useCallback(
(data: FlowchartData) => {
setNodes(toReactFlowNodes(data.nodes))
setEdges(toReactFlowEdges(data.edges))
setToast({ message: 'Project imported successfully', type: 'success' })
},
[setNodes, setEdges]
)
// Handle file selection from file picker
const handleFileSelect = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// Reset file input so same file can be selected again
event.target.value = ''
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const parsedData = JSON.parse(content)
// Validate the imported data
if (!isValidFlowchartData(parsedData)) {
setToast({
message: 'Invalid file format. File must contain nodes and edges arrays.',
type: 'error',
})
return
}
// Check if current project has unsaved changes
if (hasUnsavedChanges()) {
// Show confirmation dialog
setImportConfirmDialog({ pendingData: parsedData })
} else {
// Load data directly
loadImportedData(parsedData)
}
} catch {
setToast({
message: 'Failed to parse file. Please ensure it is valid JSON.',
type: 'error',
})
}
}
reader.onerror = () => {
setToast({ message: 'Failed to read file.', type: 'error' })
}
reader.readAsText(file)
},
[hasUnsavedChanges, loadImportedData]
)
// Handle import button click - opens file picker
const handleImport = useCallback(() => {
fileInputRef.current?.click()
}, [])
const handleImport = useCallback(() => {
// TODO: Implement in US-036
// Confirm import (discard unsaved changes)
const handleConfirmImport = useCallback(() => {
if (importConfirmDialog?.pendingData) {
loadImportedData(importConfirmDialog.pendingData)
}
setImportConfirmDialog(null)
}, [importConfirmDialog, loadImportedData])
// Cancel import
const handleCancelImport = useCallback(() => {
setImportConfirmDialog(null)
}, [])
// Handle edge deletion via keyboard (Delete/Backspace)
+3 -25
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>
)
+46 -6
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'
@@ -80,13 +79,14 @@ export default async function EditorPage({ params }: PageProps) {
// the project was created before these features existed and may need auto-migration
const needsMigration = !rawData.characters && !rawData.variables
return (
return (<>
<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">
<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"
@@ -101,9 +101,35 @@ 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>
@@ -117,6 +143,20 @@ export default async function EditorPage({ params }: PageProps) {
needsMigration={needsMigration}
/>
</div>
</div>
</>
)
}
const flowchartData = (project.flowchart_data || {
nodes: [],
edges: [],
}) as FlowchartData
return (
<FlowchartEditor
projectId={project.id}
projectName={project.name}
initialData={flowchartData}
/>
)
}
+128
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>
)
}
+17 -122
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>
)
}
+2 -62
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')
}
+236
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>
)
}
+15 -230
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>
)
}
+40
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>
)
}
+6
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>
+170
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>
)
}
+17 -2
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"
+131
View File
@@ -0,0 +1,131 @@
'use client'
import { useCallback, useEffect } from 'react'
export type ContextMenuType = 'canvas' | 'node' | 'edge'
type ContextMenuProps = {
x: number
y: number
type: ContextMenuType
onClose: () => void
onAddDialogue?: () => void
onAddChoice?: () => void
onAddVariable?: () => void
onDelete?: () => void
onAddCondition?: () => void
}
export default function ContextMenu({
x,
y,
type,
onClose,
onAddDialogue,
onAddChoice,
onAddVariable,
onDelete,
onAddCondition,
}: ContextMenuProps) {
// Close menu on Escape key
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
},
[onClose]
)
// Close menu on click outside
const handleClickOutside = useCallback(() => {
onClose()
}, [onClose])
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', handleClickOutside)
}
}, [handleKeyDown, handleClickOutside])
const menuItemClass =
'w-full px-4 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700 cursor-pointer'
return (
<div
className="fixed z-50 min-w-40 rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-800"
style={{ left: x, top: y }}
onClick={(e) => e.stopPropagation()}
>
{type === 'canvas' && (
<>
<button
className={`${menuItemClass} text-blue-600 dark:text-blue-400`}
onClick={() => {
onAddDialogue?.()
onClose()
}}
>
Add Dialogue
</button>
<button
className={`${menuItemClass} text-green-600 dark:text-green-400`}
onClick={() => {
onAddChoice?.()
onClose()
}}
>
Add Choice
</button>
<button
className={`${menuItemClass} text-orange-600 dark:text-orange-400`}
onClick={() => {
onAddVariable?.()
onClose()
}}
>
Add Variable
</button>
</>
)}
{type === 'node' && (
<button
className={`${menuItemClass} text-red-600 dark:text-red-400`}
onClick={() => {
onDelete?.()
onClose()
}}
>
Delete
</button>
)}
{type === 'edge' && (
<>
<button
className={`${menuItemClass} text-red-600 dark:text-red-400`}
onClick={() => {
onDelete?.()
onClose()
}}
>
Delete
</button>
<button
className={menuItemClass}
onClick={() => {
onAddCondition?.()
onClose()
}}
>
Add Condition
</button>
</>
)}
</div>
)
}
+33 -2
View File
@@ -9,6 +9,7 @@ type ToolbarProps = {
onAddVariable: () => void
onSave: () => void
onExport: () => void
onExportRenpy: () => void
onImport: () => void
onProjectSettings: () => void
onShare: () => void
@@ -36,6 +37,7 @@ export default function Toolbar({
onAddVariable,
onSave,
onExport,
onExportRenpy,
onImport,
onProjectSettings,
onShare,
@@ -96,9 +98,32 @@ export default function Toolbar({
</button>
<button
onClick={onSave}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
disabled={isSaving}
className="flex items-center gap-1.5 rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
>
Save
{isSaving && (
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
onClick={onExport}
@@ -106,6 +131,12 @@ export default function Toolbar({
>
Export
</button>
<button
onClick={onExportRenpy}
className="rounded border border-purple-400 bg-purple-50 px-3 py-1.5 text-sm font-medium text-purple-700 hover:bg-purple-100 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:border-purple-500 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50 dark:focus:ring-offset-zinc-800"
>
Export to Ren&apos;Py
</button>
<button
onClick={onImport}
className="rounded border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
@@ -0,0 +1,71 @@
'use client'
import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getSmoothStepPath,
} from 'reactflow'
import type { Condition } from '@/types/flowchart'
type ConditionalEdgeData = {
condition?: Condition
}
export default function ConditionalEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
selected,
}: EdgeProps<ConditionalEdgeData>) {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
const hasCondition = !!data?.condition
// Format condition as readable label
const conditionLabel = hasCondition
? `${data.condition!.variableName} ${data.condition!.operator} ${data.condition!.value}`
: null
return (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
strokeDasharray: hasCondition ? '5 5' : undefined,
stroke: selected ? '#3b82f6' : hasCondition ? '#f59e0b' : '#64748b',
strokeWidth: selected ? 2 : 1.5,
}}
/>
{conditionLabel && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
}}
className="rounded border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 dark:border-amber-600 dark:bg-amber-900 dark:text-amber-200"
>
{conditionLabel}
</div>
</EdgeLabelRenderer>
)}
</>
)
}
+85 -4
View File
@@ -23,7 +23,6 @@ const MAX_OPTIONS = 6
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
const { setNodes } = useReactFlow()
const { variables } = useEditorContext()
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
const updatePrompt = useCallback(
@@ -228,13 +227,95 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
</button>
</div>
<input
type="text"
value={data.prompt || ''}
onChange={updatePrompt}
placeholder="What do you choose?"
className="mb-3 w-full 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"
/>
<div className="space-y-2">
{data.options.map((option, index) => (
<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>
<button
type="button"
onClick={addOption}
disabled={data.options.length >= MAX_OPTIONS}
className="mt-2 flex w-full items-center justify-center gap-1 rounded border border-dashed border-green-400 py-1 text-sm text-green-600 hover:bg-green-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:border-green-500 dark:text-green-400 dark:hover:bg-green-900/30"
title="Add option"
>
+ Add Option
</button>
{editingOption && (
<OptionConditionEditor
optionId={editingOption.id}
optionLabel={editingOption.label}
condition={editingOption.condition}
onChange={(condition) => updateOptionCondition(editingOption.id, condition)}
onClose={() => setEditingConditionOptionId(null)}
onSave={handleSaveCondition}
onRemove={handleRemoveCondition}
onCancel={() => setEditingConditionOptionId(null)}
/>
)}
</>
</div>
)
}