Compare commits
No commits in common. "4fbc5649443e38d2174aaec21cdd8ed4ff6e90f4" and "dd8fcb79cf7bd54d706502fe76df2552e6caefc1" have entirely different histories.
4fbc564944
...
dd8fcb79cf
53
prd.json
53
prd.json
|
|
@ -694,59 +694,6 @@
|
||||||
"priority": 39,
|
"priority": 39,
|
||||||
"passes": true,
|
"passes": true,
|
||||||
"notes": ""
|
"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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
progress.txt
46
progress.txt
|
|
@ -26,7 +26,6 @@
|
||||||
- Use nanoid for generating unique node IDs (import from 'nanoid')
|
- 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
|
- 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
|
- 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -587,48 +586,3 @@
|
||||||
- LoadingSpinner uses size prop ('sm' | 'md' | 'lg') for flexibility across different contexts
|
- 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)
|
- 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
|
|
||||||
---
|
|
||||||
|
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -297,11 +297,6 @@ function convertToRenpyFormat(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-option condition (visibility condition) takes priority over edge condition
|
|
||||||
if (option.condition) {
|
|
||||||
choice.condition = option.condition
|
|
||||||
}
|
|
||||||
|
|
||||||
return choice
|
return choice
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Suspense } from 'react'
|
import { Suspense } from 'react'
|
||||||
import LoginForm from './LoginForm'
|
import LoginForm from './LoginForm'
|
||||||
import PasswordResetModal from '@/components/PasswordResetModal'
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -19,7 +18,6 @@ export default function LoginPage() {
|
||||||
>
|
>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<PasswordResetModal />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,6 @@ export default function Navbar({ userEmail, isAdmin }: NavbarProps) {
|
||||||
Invite User
|
Invite User
|
||||||
</Link>
|
</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">
|
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
{userEmail}
|
{userEmail}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react'
|
|
||||||
import type { Condition } from '@/types/flowchart'
|
|
||||||
|
|
||||||
type OptionConditionEditorProps = {
|
|
||||||
optionId: string
|
|
||||||
optionLabel: string
|
|
||||||
condition?: Condition
|
|
||||||
onSave: (optionId: string, condition: Condition) => void
|
|
||||||
onRemove: (optionId: string) => void
|
|
||||||
onCancel: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
|
|
||||||
|
|
||||||
export default function OptionConditionEditor({
|
|
||||||
optionId,
|
|
||||||
optionLabel,
|
|
||||||
condition,
|
|
||||||
onSave,
|
|
||||||
onRemove,
|
|
||||||
onCancel,
|
|
||||||
}: OptionConditionEditorProps) {
|
|
||||||
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
|
|
||||||
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
|
|
||||||
const [value, setValue] = useState(condition?.value ?? 0)
|
|
||||||
|
|
||||||
// Close on Escape key
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
onCancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
||||||
}, [onCancel])
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
if (!variableName.trim()) return
|
|
||||||
onSave(optionId, {
|
|
||||||
variableName: variableName.trim(),
|
|
||||||
operator,
|
|
||||||
value,
|
|
||||||
})
|
|
||||||
}, [optionId, variableName, operator, value, onSave])
|
|
||||||
|
|
||||||
const handleRemove = useCallback(() => {
|
|
||||||
onRemove(optionId)
|
|
||||||
}, [optionId, onRemove])
|
|
||||||
|
|
||||||
const hasExistingCondition = !!condition
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
||||||
<div
|
|
||||||
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h3 className="mb-1 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
|
||||||
{hasExistingCondition ? 'Edit Option Condition' : 'Add Option Condition'}
|
|
||||||
</h3>
|
|
||||||
<p className="mb-4 text-sm text-zinc-500 dark:text-zinc-400">
|
|
||||||
Option: {optionLabel || '(unnamed)'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Variable Name Input */}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="optionVariableName"
|
|
||||||
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
|
||||||
>
|
|
||||||
Variable Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="optionVariableName"
|
|
||||||
value={variableName}
|
|
||||||
onChange={(e) => setVariableName(e.target.value)}
|
|
||||||
placeholder="e.g., affection, score, health"
|
|
||||||
autoFocus
|
|
||||||
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Operator Dropdown */}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="optionOperator"
|
|
||||||
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
|
||||||
>
|
|
||||||
Operator
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="optionOperator"
|
|
||||||
value={operator}
|
|
||||||
onChange={(e) => setOperator(e.target.value as Condition['operator'])}
|
|
||||||
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
|
|
||||||
>
|
|
||||||
{OPERATORS.map((op) => (
|
|
||||||
<option key={op} value={op}>
|
|
||||||
{op === '==' ? '== (equals)' : op === '!=' ? '!= (not equals)' : op === '>' ? '> (greater than)' : op === '<' ? '< (less than)' : op === '>=' ? '>= (greater or equal)' : '<= (less or equal)'}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Value Number Input */}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="optionValue"
|
|
||||||
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
|
||||||
>
|
|
||||||
Value
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="optionValue"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(parseFloat(e.target.value) || 0)}
|
|
||||||
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
{variableName.trim() && (
|
|
||||||
<div className="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-700">
|
|
||||||
<span className="text-sm text-zinc-600 dark:text-zinc-300">
|
|
||||||
Show option when: <code className="font-mono text-amber-600 dark:text-amber-400">{variableName.trim()} {operator} {value}</code>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="mt-6 flex justify-between">
|
|
||||||
<div>
|
|
||||||
{hasExistingCondition && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRemove}
|
|
||||||
className="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!variableName.trim()}
|
|
||||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useState, ChangeEvent } from 'react'
|
import { useCallback, ChangeEvent } from 'react'
|
||||||
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
import { Handle, Position, NodeProps, useReactFlow } from 'reactflow'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import type { Condition } from '@/types/flowchart'
|
|
||||||
import OptionConditionEditor from '@/components/editor/OptionConditionEditor'
|
|
||||||
|
|
||||||
type ChoiceOption = {
|
type ChoiceOption = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
condition?: Condition
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChoiceNodeData = {
|
type ChoiceNodeData = {
|
||||||
|
|
@ -22,7 +19,6 @@ const MAX_OPTIONS = 6
|
||||||
|
|
||||||
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
const { setNodes } = useReactFlow()
|
const { setNodes } = useReactFlow()
|
||||||
const [editingConditionOptionId, setEditingConditionOptionId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const updatePrompt = useCallback(
|
const updatePrompt = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -100,57 +96,6 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
[id, data.options.length, setNodes]
|
[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 (
|
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">
|
<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
|
<Handle
|
||||||
|
|
@ -174,61 +119,32 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{data.options.map((option, index) => (
|
{data.options.map((option, index) => (
|
||||||
<div key={option.id}>
|
<div key={option.id} className="relative flex items-center gap-1">
|
||||||
<div className="relative flex items-center gap-1">
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={option.label}
|
||||||
value={option.label}
|
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
placeholder={`Option ${index + 1}`}
|
||||||
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"
|
||||||
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
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => removeOption(option.id)}
|
||||||
onClick={() => setEditingConditionOptionId(option.id)}
|
disabled={data.options.length <= MIN_OPTIONS}
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
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"
|
||||||
option.condition
|
title="Remove option"
|
||||||
? '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'
|
×
|
||||||
}`}
|
</button>
|
||||||
title={option.condition ? `Condition: ${option.condition.variableName} ${option.condition.operator} ${option.condition.value}` : 'Add condition'}
|
<Handle
|
||||||
>
|
type="source"
|
||||||
{option.condition ? (
|
position={Position.Bottom}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
id={`option-${index}`}
|
||||||
<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" />
|
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
||||||
</svg>
|
style={{
|
||||||
) : (
|
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
}}
|
||||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
/>
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeOption(option.id)}
|
|
||||||
disabled={data.options.length <= MIN_OPTIONS}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded text-red-500 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent dark:hover:bg-red-900/30"
|
|
||||||
title="Remove option"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={`option-${index}`}
|
|
||||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
|
||||||
style={{
|
|
||||||
left: `${((index + 1) / (data.options.length + 1)) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{option.condition && (
|
|
||||||
<div className="ml-1 mt-0.5 flex items-center gap-1">
|
|
||||||
<span className="inline-flex items-center rounded-sm bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
|
||||||
if {option.condition.variableName} {option.condition.operator} {option.condition.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -242,17 +158,6 @@ export default function ChoiceNode({ id, data }: NodeProps<ChoiceNodeData>) {
|
||||||
>
|
>
|
||||||
+ Add Option
|
+ Add Option
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{editingOption && (
|
|
||||||
<OptionConditionEditor
|
|
||||||
optionId={editingOption.id}
|
|
||||||
optionLabel={editingOption.label}
|
|
||||||
condition={editingOption.condition}
|
|
||||||
onSave={handleSaveCondition}
|
|
||||||
onRemove={handleRemoveCondition}
|
|
||||||
onCancel={() => setEditingConditionOptionId(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,6 @@ export type Position = {
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Condition type for conditional edges and choice options
|
|
||||||
export type Condition = {
|
|
||||||
variableName: string;
|
|
||||||
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// DialogueNode type: represents character speech/dialogue
|
// DialogueNode type: represents character speech/dialogue
|
||||||
export type DialogueNode = {
|
export type DialogueNode = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -26,7 +19,6 @@ export type DialogueNode = {
|
||||||
export type ChoiceOption = {
|
export type ChoiceOption = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
condition?: Condition;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ChoiceNode type: represents branching decisions
|
// ChoiceNode type: represents branching decisions
|
||||||
|
|
@ -55,6 +47,13 @@ export type VariableNode = {
|
||||||
// Union type for all node types
|
// Union type for all node types
|
||||||
export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode;
|
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
|
// FlowchartEdge type: represents connections between nodes
|
||||||
export type FlowchartEdge = {
|
export type FlowchartEdge = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue