Compare commits
26
Commits
+583
-48
@@ -27,73 +27,608 @@
|
|||||||
- 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
|
- Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser
|
||||||
- Character/Variable types (`Character`, `Variable`) and extracted node data types (`DialogueNodeData`, `VariableNodeData`) are in `src/types/flowchart.ts`
|
|
||||||
- New JSONB fields (characters, variables) must be defaulted to `[]` when reading from DB in page.tsx to handle pre-existing data
|
|
||||||
- Reusable `Combobox` component at `src/components/editor/Combobox.tsx` - use for all character/variable dropdowns. Props: items (ComboboxItem[]), value, onChange, placeholder, onAddNew
|
|
||||||
- `ProjectSettingsModal` at `src/components/editor/ProjectSettingsModal.tsx` manages characters/variables. Receives state + callbacks from FlowchartEditor
|
|
||||||
- Characters and variables state is managed in `FlowchartEditorInner` with `useState` hooks, passed down to the modal
|
|
||||||
- For settings-style modals, use `max-w-2xl h-[80vh]` with overflow-y-auto content area and fixed header/tabs
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-054
|
## 2026-01-21 - US-001
|
||||||
- What was implemented: Character and Variable TypeScript types added to `src/types/flowchart.ts`
|
- What was implemented: Project scaffolding and configuration
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `src/types/flowchart.ts` - Added `Character`, `Variable`, `DialogueNodeData`, `VariableNodeData` types; updated `FlowchartData`, `DialogueNode`, `VariableNode`, `Condition` types
|
- package.json - project dependencies and scripts
|
||||||
- `src/app/editor/[projectId]/page.tsx` - Updated FlowchartData initialization to include `characters: []` and `variables: []` defaults
|
- tsconfig.json - TypeScript configuration
|
||||||
|
- next.config.ts - Next.js configuration
|
||||||
|
- postcss.config.mjs - PostCSS with TailwindCSS
|
||||||
|
- eslint.config.mjs - ESLint configuration
|
||||||
|
- .env.example - environment variables template
|
||||||
|
- .gitignore - git ignore rules
|
||||||
|
- src/app/ - Next.js App Router pages
|
||||||
|
- src/components/.gitkeep - components directory placeholder
|
||||||
|
- src/lib/.gitkeep - lib directory placeholder
|
||||||
|
- src/types/.gitkeep - types directory placeholder
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- The node components (`DialogueNode.tsx`, `VariableNode.tsx`, `ChoiceNode.tsx`) define their own local data types that mirror the global types. When adding fields, both the global type and local component type may need updating in later stories.
|
- Next.js 16 uses `@tailwindcss/postcss` for TailwindCSS 4 integration
|
||||||
- `flowchart_data` is a JSONB column in Supabase, so it comes as `any` type. Always provide defaults for new fields when reading from DB to handle existing data without those fields.
|
- Use --src-dir flag for create-next-app to put source in src/ folder
|
||||||
- The new `characterId` and `variableId` fields are optional alongside existing `speaker`/`variableName` fields to support migration from free-text to referenced-entity pattern.
|
- npm package names can't have capital letters (use lowercase)
|
||||||
|
- .gitignore needs explicit exclusion for .env files, but include .env.example
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-055
|
## 2026-01-21 - US-002
|
||||||
- What was implemented: Database migration to update flowchart_data JSONB default to include `characters: []` and `variables: []`
|
- What was implemented: TypeScript types for flowchart data structures
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `supabase/migrations/20260123000000_add_characters_variables_to_flowchart_data.sql` - New migration that alters the default value for the flowchart_data column and documents the expected JSONB structure
|
- src/types/flowchart.ts - new file with all flowchart type definitions
|
||||||
|
- package.json - added typecheck script (tsc --noEmit)
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- Since characters and variables are stored within the existing flowchart_data JSONB column (not as separate tables), schema changes are minimal - just updating the column default. The real data integrity is handled at the application layer.
|
- Position is a helper type for {x, y} coordinates used by nodes
|
||||||
- The app-side defaults in page.tsx (from US-054) already handle existing projects gracefully, so no data migration of existing rows is needed.
|
- FlowchartNode is a union type of DialogueNode | ChoiceNode | VariableNode
|
||||||
- For JSONB-embedded arrays, the pattern is: update the DB default for new rows + handle missing fields in app code for old rows.
|
- ChoiceOption is a separate type to make options array cleaner
|
||||||
|
- All types use `export type` for TypeScript isolatedModules compatibility
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-065
|
## 2026-01-21 - US-003
|
||||||
- What was implemented: Reusable searchable combobox component at `src/components/editor/Combobox.tsx`
|
- What was implemented: Supabase schema for users and projects
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `src/components/editor/Combobox.tsx` - New component with searchable dropdown, keyboard navigation, color swatches, badges, "Add new..." option, and auto-positioning
|
- supabase/migrations/20260121000000_create_profiles_and_projects.sql - new file with all database schema
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- The Combobox exports both the default component and the `ComboboxItem` type for consumers to use
|
- Supabase migrations are plain SQL files in supabase/migrations/ directory
|
||||||
- Props: `items` (ComboboxItem[]), `value` (string | undefined), `onChange` (id: string) => void, `placeholder` (string), `onAddNew` (() => void, optional)
|
- Migration filenames use timestamp prefix (YYYYMMDDHHMMSS_description.sql)
|
||||||
- ComboboxItem shape: `{ id: string, label: string, color?: string, badge?: string }`
|
- RLS policies need separate policies for SELECT, INSERT, UPDATE, DELETE operations
|
||||||
- The component uses neutral zinc colors for borders/backgrounds (not blue/green/orange) so it can be reused across different node types
|
- Admin check policy uses EXISTS subquery to check is_admin flag on profiles table
|
||||||
- Dropdown auto-positions above or below based on available viewport space (200px threshold)
|
- projects table references profiles.id (not auth.users.id directly) for proper FK relationships
|
||||||
- Keyboard: ArrowDown/Up navigate, Enter selects, Escape closes
|
- flowchart_data column uses JSONB type with default empty structure
|
||||||
- The component is designed to be a drop-in replacement for text inputs in node components (same `w-full` and `text-sm` sizing)
|
- Added auto-update trigger for updated_at timestamp on projects table
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-056
|
## 2026-01-21 - US-004
|
||||||
- What was implemented: Character management UI in the project settings modal
|
- What was implemented: Supabase client configuration utilities
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `src/components/editor/ProjectSettingsModal.tsx` - New modal component with Characters and Variables tabs; Characters tab has full CRUD (add, edit, delete with usage warnings), name uniqueness validation, color picker, inline forms
|
- src/lib/supabase/client.ts - browser client using createBrowserClient from @supabase/ssr
|
||||||
- `src/components/editor/Toolbar.tsx` - Added `onProjectSettings` prop and "Project Settings" button to the right side of the toolbar
|
- src/lib/supabase/server.ts - server client for App Router with async cookies() API
|
||||||
- `src/app/editor/[projectId]/FlowchartEditor.tsx` - Added `characters` and `variables` state management, `showSettings` modal state, usage count helpers (`getCharacterUsageCount`, `getVariableUsageCount`), and ProjectSettingsModal rendering
|
- src/lib/supabase/middleware.ts - middleware helper with updateSession function
|
||||||
|
- src/lib/.gitkeep - removed (no longer needed)
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- The ProjectSettingsModal receives `onCharactersChange` and `onVariablesChange` callbacks that directly set state in FlowchartEditor. When save is implemented, it should read from this state.
|
- @supabase/ssr package provides createBrowserClient and createServerClient functions
|
||||||
- The Variables tab is a read-only placeholder in US-056; US-057 will implement the full CRUD for variables using the same patterns (inline forms, validation, delete warnings).
|
- Server client requires async cookies() from next/headers in Next.js 16
|
||||||
- Modal pattern: fixed inset-0 z-50 with backdrop click to close, max-w-2xl for settings modals (larger than max-w-md used for simple dialogs).
|
- Middleware client returns both user object and supabaseResponse for route protection
|
||||||
- Character usage count checks dialogue nodes for `data.characterId`; variable usage count checks both variable nodes and edge conditions.
|
- Cookie handling uses getAll/setAll pattern for proper session management
|
||||||
- The `randomHexColor()` utility picks from a curated list of 12 vibrant colors for character defaults.
|
- setAll in server.ts wrapped in try/catch to handle Server Component limitations
|
||||||
- No browser testing tools are available; manual verification is needed.
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-01-23 - US-057
|
## 2026-01-21 - US-005
|
||||||
- What was implemented: Variable management UI with full CRUD in the project settings modal Variables tab
|
- What was implemented: Protected routes middleware for authentication
|
||||||
- Files changed:
|
- Files changed:
|
||||||
- `src/components/editor/ProjectSettingsModal.tsx` - Replaced placeholder VariablesTab with full implementation: add/edit/delete with inline forms, type dropdown (numeric/string/boolean), type-adaptive initial value input (number input for numeric, text for string, select for boolean), name uniqueness validation, delete warnings with usage count, colored type badges
|
- middleware.ts - new file at project root for route protection
|
||||||
- **Learnings for future iterations:**
|
- **Learnings for future iterations:**
|
||||||
- The VariableForm uses a `handleTypeChange` helper that resets the initial value to the type's default when the type changes, preventing invalid state (e.g., "hello" as a numeric value)
|
- Next.js middleware.ts must be at project root (not in src/)
|
||||||
- Initial values are stored as strings in form state and parsed to the correct type (number/string/boolean) on save via `parseInitialValue()`
|
- updateSession helper from lib/supabase/middleware.ts returns { user, supabaseResponse }
|
||||||
- Type badges use distinct colors: blue for numeric, green for string, purple for boolean - making variable types instantly recognizable in the list
|
- Use startsWith() for route matching to handle nested routes (e.g., /editor/*)
|
||||||
- The same form patterns from CharactersTab apply: inline form within the list for editing, appended form below the list for adding
|
- Matcher config excludes static files and images to avoid unnecessary middleware calls
|
||||||
- No browser testing tools are available; manual verification is needed.
|
- Clone nextUrl before modifying pathname for redirects
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-006
|
||||||
|
- What was implemented: Login page with email/password authentication
|
||||||
|
- Files changed:
|
||||||
|
- src/app/login/page.tsx - new file with login form and Supabase auth
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Auth pages use 'use client' directive since they need useState and form handling
|
||||||
|
- Use createClient() from lib/supabase/client.ts for browser-side auth operations
|
||||||
|
- supabase.auth.signInWithPassword returns { error } object for handling failures
|
||||||
|
- useRouter from next/navigation for programmatic redirects after auth
|
||||||
|
- Error state displayed in red alert box with dark mode support
|
||||||
|
- Loading state disables submit button and shows "Signing in..." text
|
||||||
|
- TailwindCSS dark mode uses dark: prefix (e.g., dark:bg-zinc-950)
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-007
|
||||||
|
- What was implemented: Sign up page for invite-only account setup
|
||||||
|
- Files changed:
|
||||||
|
- src/app/signup/page.tsx - new file with signup form and Supabase auth
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Supabase invite tokens come via URL hash fragment (window.location.hash)
|
||||||
|
- Parse hash with URLSearchParams after removing leading '#'
|
||||||
|
- Check for type=invite or type=signup to detect invite flow
|
||||||
|
- Use setSession() with access_token and refresh_token to establish session from invite link
|
||||||
|
- For invited users, update password with updateUser() then create profile with upsert()
|
||||||
|
- Use upsert() instead of insert() for profiles to handle edge cases
|
||||||
|
- Validate password confirmation before submission (passwords match check)
|
||||||
|
- display_name defaults to email prefix (split('@')[0])
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-008
|
||||||
|
- What was implemented: Logout functionality component
|
||||||
|
- Files changed:
|
||||||
|
- src/components/LogoutButton.tsx - new client component with signOut and redirect
|
||||||
|
- src/components/.gitkeep - removed (no longer needed)
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- LogoutButton is a reusable component that will be used in the navbar (US-011)
|
||||||
|
- Component uses 'use client' directive for client-side auth operations
|
||||||
|
- Loading state prevents double-clicks during signOut
|
||||||
|
- Styled with neutral zinc colors to work as a secondary button in navbars
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-009
|
||||||
|
- What was implemented: Password reset - forgot password page
|
||||||
|
- Files changed:
|
||||||
|
- src/app/forgot-password/page.tsx - new file with forgot password form and email reset
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- resetPasswordForEmail requires redirectTo option to specify where user lands after clicking reset link
|
||||||
|
- Use `window.location.origin` to get the current site URL for redirectTo
|
||||||
|
- Page shows different UI after success (conditional rendering with success state)
|
||||||
|
- Use ' for apostrophe in JSX to avoid HTML entity issues
|
||||||
|
- Follow same styling pattern as login page for consistency across auth pages
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-010
|
||||||
|
- What was implemented: Password reset - set new password page
|
||||||
|
- Files changed:
|
||||||
|
- src/app/reset-password/page.tsx - new file with password reset form
|
||||||
|
- src/app/login/page.tsx - updated to show success message from password reset
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Supabase recovery tokens come via URL hash fragment with type=recovery
|
||||||
|
- Use setSession() with access_token and refresh_token from hash to establish recovery session
|
||||||
|
- Show loading state while verifying token validity (tokenValid === null)
|
||||||
|
- Show error state with link to request new reset if token is invalid
|
||||||
|
- After password update, sign out the user and redirect to login with success message
|
||||||
|
- Use query param (message=password_reset_success) to pass success state between pages
|
||||||
|
- Login page uses useSearchParams to read and display success messages
|
||||||
|
- Success messages styled with green background (bg-green-50)
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-011
|
||||||
|
- What was implemented: Dashboard layout with navbar component
|
||||||
|
- Files changed:
|
||||||
|
- src/app/dashboard/layout.tsx - new file with dashboard layout wrapper
|
||||||
|
- src/components/Navbar.tsx - new reusable navbar component
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Dashboard layout is a server component that fetches user data via createClient() from lib/supabase/server.ts
|
||||||
|
- Navbar accepts userEmail prop to display current user
|
||||||
|
- Layout wraps children with consistent max-w-7xl container and padding
|
||||||
|
- Navbar uses Link component to allow clicking app title to go back to dashboard
|
||||||
|
- Navbar has border-b styling with dark mode support for visual separation
|
||||||
|
- Use gap-4 for spacing between navbar items (user email and logout button)
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-012
|
||||||
|
- What was implemented: Dashboard page listing user projects
|
||||||
|
- Files changed:
|
||||||
|
- src/app/dashboard/page.tsx - new file with project listing, cards, and empty state
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Dashboard page is a server component that fetches projects directly from Supabase
|
||||||
|
- Use .eq('user_id', user.id) for RLS-backed queries (though RLS also enforces this)
|
||||||
|
- Order by updated_at descending to show most recent projects first
|
||||||
|
- formatDate() helper with toLocaleDateString for human-readable dates
|
||||||
|
- Project cards use Link component for navigation to /editor/[projectId]
|
||||||
|
- Empty state uses dashed border (border-dashed) with centered content and icon
|
||||||
|
- Hover effects on cards: border-blue-300, shadow-md, and text color change on title
|
||||||
|
- Error state displayed if Supabase query fails
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-013
|
||||||
|
- What was implemented: Create new project functionality
|
||||||
|
- Files changed:
|
||||||
|
- src/components/NewProjectButton.tsx - new client component with modal dialog
|
||||||
|
- src/app/dashboard/page.tsx - added NewProjectButton to header area
|
||||||
|
- src/app/signup/page.tsx - fixed lint error (setState in effect) by initializing email from searchParams
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Modal dialogs use fixed positioning with backdrop (bg-black/50) for overlay effect
|
||||||
|
- Form submission uses Supabase insert with .select('id').single() to get the new record ID
|
||||||
|
- Initialize flowchart_data with { nodes: [], edges: [] } for new projects
|
||||||
|
- router.push() for programmatic navigation after successful creation
|
||||||
|
- autoFocus on input for better UX when modal opens
|
||||||
|
- Prevent modal close while loading (check isLoading before calling handleClose)
|
||||||
|
- ESLint rule react-hooks/set-state-in-effect warns against synchronous setState in useEffect
|
||||||
|
- Initialize state from searchParams directly in useState() instead of setting in useEffect
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-014
|
||||||
|
- What was implemented: Delete project functionality with confirmation dialog and toast
|
||||||
|
- Files changed:
|
||||||
|
- src/components/ProjectCard.tsx - new client component replacing Link, with delete button and confirmation dialog
|
||||||
|
- src/components/ProjectList.tsx - new wrapper component to manage project list state and toast notifications
|
||||||
|
- src/components/Toast.tsx - new reusable toast notification component
|
||||||
|
- src/app/dashboard/page.tsx - updated to use ProjectList instead of inline rendering
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- To enable client-side state updates (like removing items), extract list rendering from server components into client components
|
||||||
|
- ProjectList accepts initialProjects from server and manages state locally for immediate UI updates
|
||||||
|
- Use onDelete callback pattern to propagate deletion events from child (ProjectCard) to parent (ProjectList)
|
||||||
|
- Delete button uses e.stopPropagation() to prevent card click navigation when clicking delete
|
||||||
|
- Confirmation dialogs should disable close/cancel while action is in progress (isDeleting check)
|
||||||
|
- Toast component uses useCallback for handlers and auto-dismiss with setTimeout
|
||||||
|
- Toast animations can use TailwindCSS animate-in utilities (fade-in, slide-in-from-bottom-4)
|
||||||
|
- Delete icon appears on hover using group-hover:opacity-100 with parent group class
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-015
|
||||||
|
- What was implemented: Rename project functionality
|
||||||
|
- Files changed:
|
||||||
|
- src/components/ProjectCard.tsx - added rename button, modal dialog, and Supabase update logic
|
||||||
|
- src/components/ProjectList.tsx - added handleRename callback and toast notification
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Multiple action buttons on a card can be grouped in a flex container with gap-1
|
||||||
|
- Rename modal follows same pattern as delete dialog: fixed positioning, backdrop, form
|
||||||
|
- Use onKeyDown to handle Enter key for quick form submission
|
||||||
|
- Reset form state (newName, error) when opening modal to handle edge cases
|
||||||
|
- Check if name is unchanged before making API call to avoid unnecessary requests
|
||||||
|
- Trim whitespace from input value before validation and submission
|
||||||
|
- handleRename callback updates project name in state using map() to preserve list order
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-016
|
||||||
|
- What was implemented: Admin invite user functionality
|
||||||
|
- Files changed:
|
||||||
|
- src/app/admin/invite/page.tsx - new admin-only page with access check (redirects non-admins)
|
||||||
|
- src/app/admin/invite/InviteForm.tsx - client component with invite form and state management
|
||||||
|
- src/app/admin/invite/actions.ts - server action using service role key to call inviteUserByEmail
|
||||||
|
- src/components/Navbar.tsx - added isAdmin prop and "Invite User" link (visible only to admins)
|
||||||
|
- src/app/dashboard/layout.tsx - fetches profile.is_admin and passes it to Navbar
|
||||||
|
- .env.example - added SUPABASE_SERVICE_ROLE_KEY and NEXT_PUBLIC_SITE_URL
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Admin operations require SUPABASE_SERVICE_ROLE_KEY (server-side only, not NEXT_PUBLIC_*)
|
||||||
|
- Use createClient from @supabase/supabase-js directly for admin client (not @supabase/ssr)
|
||||||
|
- Admin client needs auth config: { autoRefreshToken: false, persistSession: false }
|
||||||
|
- inviteUserByEmail requires redirectTo option for the signup link in email
|
||||||
|
- Server actions ('use server') can access private env vars safely
|
||||||
|
- Admin check should happen both in server component (redirect) and server action (double check)
|
||||||
|
- Admin page uses its own layout (not dashboard layout) to have custom styling
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-017
|
||||||
|
- What was implemented: Editor page with React Flow canvas
|
||||||
|
- Files changed:
|
||||||
|
- package.json - added reactflow dependency
|
||||||
|
- src/app/editor/[projectId]/page.tsx - new server component that fetches project from Supabase, handles auth/not found, renders header with back link
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - new client component with React Flow canvas, Background component, type converters for nodes/edges
|
||||||
|
- src/app/editor/[projectId]/loading.tsx - new loading state component with spinner
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- React Flow requires 'use client' directive since it uses browser APIs
|
||||||
|
- Import 'reactflow/dist/style.css' for default React Flow styling
|
||||||
|
- Use useNodesState and useEdgesState hooks for managing nodes/edges state
|
||||||
|
- Convert app types (FlowchartNode, FlowchartEdge) to React Flow types with helper functions
|
||||||
|
- Next.js dynamic route params come as Promise in App Router 16+ (need to await params)
|
||||||
|
- Use notFound() from next/navigation for 404 responses
|
||||||
|
- React Flow canvas needs parent container with explicit height (h-full, h-screen)
|
||||||
|
- Background component accepts variant (Dots, Lines, Cross) and gap/size props
|
||||||
|
- Loading page (loading.tsx) provides automatic loading UI for async server components
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-018
|
||||||
|
- What was implemented: Canvas pan and zoom controls
|
||||||
|
- Files changed:
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - added Controls import and component
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- React Flow Controls component provides zoom +/-, fitView, and lock buttons out of the box
|
||||||
|
- Use position="bottom-right" prop to position controls in bottom-right corner
|
||||||
|
- Pan (click-and-drag) and zoom (mouse wheel) are React Flow defaults, no extra config needed
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-019
|
||||||
|
- What was implemented: Editor toolbar with add/save/export/import buttons
|
||||||
|
- Files changed:
|
||||||
|
- src/components/editor/Toolbar.tsx - new toolbar component with styled buttons
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated toolbar with placeholder handlers
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Toolbar component accepts callback props for actions (onAddDialogue, onSave, etc.)
|
||||||
|
- Node type buttons use color coding: blue (Dialogue), green (Choice), orange (Variable)
|
||||||
|
- Action buttons (Save, Export, Import) use neutral bordered styling
|
||||||
|
- FlowchartEditor now uses flex-col layout to stack toolbar above canvas
|
||||||
|
- Placeholder handlers with TODO comments help track future implementation work
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-020
|
||||||
|
- What was implemented: Custom DialogueNode component for displaying/editing character dialogue
|
||||||
|
- Files changed:
|
||||||
|
- src/components/editor/nodes/DialogueNode.tsx - new custom node component with editable speaker and text fields
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - registered DialogueNode as custom node type
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Custom React Flow nodes use NodeProps<T> for typing, where T is the data shape
|
||||||
|
- Use useReactFlow() hook to get setNodes for updating node data from within the node component
|
||||||
|
- Handle components need Position enum (Position.Top, Position.Bottom) for positioning
|
||||||
|
- Custom handles can be styled with className and TailwindCSS, use ! prefix to override defaults (e.g., !h-3, !w-3)
|
||||||
|
- Node types must be registered in a nodeTypes object and passed to ReactFlow component
|
||||||
|
- Memoize nodeTypes with useMemo to prevent unnecessary re-renders
|
||||||
|
- Custom node components go in src/components/editor/nodes/ directory
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-021
|
||||||
|
- What was implemented: Add dialogue node from toolbar functionality
|
||||||
|
- Files changed:
|
||||||
|
- package.json - added nanoid dependency for unique ID generation
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddDialogue to create new dialogue nodes at viewport center
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- useReactFlow() hook requires ReactFlowProvider wrapper, so split component into inner component and outer wrapper
|
||||||
|
- getViewport() returns { x, y, zoom } representing the current pan/zoom state
|
||||||
|
- Calculate viewport center: centerX = (-viewport.x + halfWidth) / viewport.zoom
|
||||||
|
- nanoid v5+ generates unique IDs synchronously with no dependencies
|
||||||
|
- Node creation pattern: create Node object with { id, type, position, data }, then add to state via setNodes
|
||||||
|
- React Flow nodes are draggable by default, no extra configuration needed
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-022
|
||||||
|
- What was implemented: Custom ChoiceNode component for displaying branching decisions
|
||||||
|
- Files changed:
|
||||||
|
- src/components/editor/nodes/ChoiceNode.tsx - new custom node component with green styling, editable prompt, and dynamic option handles
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - registered ChoiceNode as custom node type
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- ChoiceNode follows same pattern as DialogueNode: NodeProps<T> typing, useReactFlow() for updates
|
||||||
|
- Dynamic handles positioned using style={{ left: `${((index + 1) / (options.length + 1)) * 100}%` }} for even spacing
|
||||||
|
- Handle id format for options: 'option-0', 'option-1', etc. (matching the index)
|
||||||
|
- Each option needs a unique id (string) and label (string) per the ChoiceOption type
|
||||||
|
- updateOptionLabel callback pattern: find option by id, map over options array to update matching one
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-023
|
||||||
|
- What was implemented: Add choice node from toolbar functionality
|
||||||
|
- Files changed:
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddChoice to create new choice nodes at viewport center
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- handleAddChoice follows same pattern as handleAddDialogue: get viewport center, create node with nanoid, add to state
|
||||||
|
- Choice nodes must be initialized with 2 options (each with unique id via nanoid and empty label)
|
||||||
|
- Node data structure for choice: { prompt: '', options: [{ id, label }, { id, label }] }
|
||||||
|
- React Flow nodes are draggable by default after being added to state
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-024
|
||||||
|
- What was implemented: Add/remove choice options functionality (2-6 options supported)
|
||||||
|
- Files changed:
|
||||||
|
- src/components/editor/nodes/ChoiceNode.tsx - added addOption and removeOption callbacks, '+' button to add options, 'x' button per option to remove
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Define MIN_OPTIONS and MAX_OPTIONS constants for clear limits
|
||||||
|
- Use disabled prop on buttons to enforce min/max constraints with appropriate styling (opacity-30, cursor-not-allowed)
|
||||||
|
- Remove button uses × character for simple cross icon
|
||||||
|
- Add button styled with border-dashed for visual distinction from action buttons
|
||||||
|
- Handles update dynamically via React Flow re-render when options array changes
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-025
|
||||||
|
- What was implemented: Custom VariableNode component for setting/modifying story variables
|
||||||
|
- Files changed:
|
||||||
|
- src/components/editor/nodes/VariableNode.tsx - new custom node component with orange styling, editable variable name, operation dropdown, and numeric value input
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - imported and registered VariableNode in nodeTypes
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- VariableNode follows same pattern as DialogueNode: NodeProps<T> typing, useReactFlow() for updates
|
||||||
|
- Use parseFloat() with fallback to 0 for number input handling: `parseFloat(e.target.value) || 0`
|
||||||
|
- Operation dropdown uses select element with options for 'set', 'add', 'subtract'
|
||||||
|
- Type assertion needed for select value: `e.target.value as 'set' | 'add' | 'subtract'`
|
||||||
|
- Use `??` (nullish coalescing) for number defaults instead of `||` to allow 0 values: `data.value ?? 0`
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-026
|
||||||
|
- What was implemented: Add variable node from toolbar functionality
|
||||||
|
- Files changed:
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleAddVariable to create new variable nodes at viewport center
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- handleAddVariable follows same pattern as handleAddDialogue and handleAddChoice: get viewport center, create node with nanoid, add to state
|
||||||
|
- Variable nodes initialized with { variableName: '', operation: 'set', value: 0 }
|
||||||
|
- All add node handlers share the same pattern and use the getViewportCenter helper
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-027
|
||||||
|
- What was implemented: Connect nodes with edges including arrow markers and smooth styling
|
||||||
|
- Files changed:
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - added MarkerType import, updated onConnect to create edges with smoothstep type and arrow markers, updated toReactFlowEdges to apply same styling to loaded edges
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Use `type: 'smoothstep'` for cleaner edge curves instead of default bezier
|
||||||
|
- Use `markerEnd: { type: MarkerType.ArrowClosed }` to add directional arrows to edges
|
||||||
|
- Connection type has nullable source/target, but Edge requires non-null strings - guard with early return
|
||||||
|
- Apply consistent edge styling in both onConnect (new edges) and toReactFlowEdges (loaded edges)
|
||||||
|
- Generate unique edge IDs with nanoid in onConnect callback
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-028
|
||||||
|
- What was implemented: Select and delete nodes functionality
|
||||||
|
- Files changed:
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - added deleteKeyCode prop to enable Delete/Backspace key deletion
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- React Flow has built-in node selection via clicking - no extra configuration needed
|
||||||
|
- Use `deleteKeyCode={['Delete', 'Backspace']}` prop to enable keyboard deletion
|
||||||
|
- React Flow automatically removes connected edges when a node is deleted (no manual cleanup needed)
|
||||||
|
- The useNodesState/useEdgesState hooks with onNodesChange/onEdgesChange handle all deletion state updates
|
||||||
|
- No explicit onNodesDelete callback is needed - the onNodesChange handler covers deletion events
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-21 - US-029
|
||||||
|
- What was implemented: Select and delete edges functionality
|
||||||
|
- Files changed:
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - added onEdgesDelete callback
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- React Flow 11 edges are clickable and selectable by default (interactionWidth renders invisible interaction area)
|
||||||
|
- The `deleteKeyCode` prop works for both nodes and edges - same configuration covers both
|
||||||
|
- onEdgesDelete is optional if you just need state management (onEdgesChange handles removal events)
|
||||||
|
- onEdgesDelete is useful for additional logic like logging, dirty state tracking, or undo/redo
|
||||||
|
- Edge selection shows visual highlight via React Flow's built-in styling
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-030
|
||||||
|
- What was implemented: Right-click context menu for canvas, nodes, and edges
|
||||||
|
- Files changed:
|
||||||
|
- src/components/editor/ContextMenu.tsx - new component with menu items for different contexts (canvas/node/edge)
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated context menu with handlers for all actions
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Use `onPaneContextMenu`, `onNodeContextMenu`, and `onEdgeContextMenu` React Flow callbacks for context menus
|
||||||
|
- `screenToFlowPosition()` converts screen coordinates to flow coordinates for placing nodes at click position
|
||||||
|
- Context menu state includes type ('canvas'|'node'|'edge') and optional nodeId/edgeId for targeted actions
|
||||||
|
- Use `document.addEventListener('click', handler)` and `e.stopPropagation()` on menu to close on outside click
|
||||||
|
- Escape key listener via `document.addEventListener('keydown', handler)` for menu close
|
||||||
|
- NodeMouseHandler and EdgeMouseHandler types from reactflow provide proper typing for context menu callbacks
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-031
|
||||||
|
- What was implemented: Condition editor modal for adding/editing/removing conditions on edges
|
||||||
|
- Files changed:
|
||||||
|
- src/components/editor/ConditionEditor.tsx - new modal component with form for variable name, operator, and value
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated condition editor with double-click and context menu triggers
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Use `onEdgeDoubleClick` React Flow callback for double-click on edges
|
||||||
|
- Store condition editor state separately from context menu state (`conditionEditor` vs `contextMenu`)
|
||||||
|
- Use `edge.data.condition` to access condition object on edges
|
||||||
|
- When removing properties from edge data, use `delete` operator instead of destructuring to avoid lint warnings about unused variables
|
||||||
|
- Condition type has operators: '>' | '<' | '==' | '>=' | '<=' | '!='
|
||||||
|
- Preview condition in modal using template string: `${variableName} ${operator} ${value}`
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-032
|
||||||
|
- What was implemented: Display conditions on edges with dashed styling and labels
|
||||||
|
- Files changed:
|
||||||
|
- src/components/editor/edges/ConditionalEdge.tsx - new custom edge component with condition display
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - integrated custom edge type, added EdgeTypes import and edgeTypes definition
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Custom React Flow edges use EdgeProps<T> typing where T is the data shape
|
||||||
|
- Use `BaseEdge` component for rendering the edge path, and `EdgeLabelRenderer` for positioning labels
|
||||||
|
- `getSmoothStepPath` returns [edgePath, labelX, labelY] - labelX/labelY are center coordinates for labels
|
||||||
|
- Custom edge types are registered in edgeTypes object (similar to nodeTypes) and passed to ReactFlow
|
||||||
|
- Style edges with conditions using strokeDasharray: '5 5' for dashed lines
|
||||||
|
- Custom edges go in `src/components/editor/edges/` directory
|
||||||
|
- Use amber color scheme for conditional edges to distinguish from regular edges
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-033
|
||||||
|
- What was implemented: Auto-save to LocalStorage with debounced saves and draft restoration prompt
|
||||||
|
- Files changed:
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - added LocalStorage auto-save functionality, draft check on load, and restoration prompt UI
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Use lazy useState initializer for draft check to avoid ESLint "setState in effect" warning
|
||||||
|
- LocalStorage key format: `vnwrite-draft-{projectId}` for project-specific drafts
|
||||||
|
- Debounce saves with 1 second delay using useRef for timer tracking
|
||||||
|
- Convert React Flow Node/Edge types back to app types using helper functions (fromReactFlowNodes, fromReactFlowEdges)
|
||||||
|
- React Flow Edge has `sourceHandle: string | null | undefined` but app types use `string | undefined` - use nullish coalescing (`?? undefined`)
|
||||||
|
- Check `typeof window === 'undefined'` in lazy initializer for SSR safety
|
||||||
|
- clearDraft is exported for use in save functionality (US-034) to clear draft after successful database save
|
||||||
|
- JSON.stringify comparison works for flowchart data equality check
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-034
|
||||||
|
- What was implemented: Save project to database functionality
|
||||||
|
- Files changed:
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleSave with Supabase update, added isSaving state and Toast notifications
|
||||||
|
- src/components/editor/Toolbar.tsx - added isSaving prop with loading spinner indicator
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Use createClient() from lib/supabase/client.ts for browser-side database operations
|
||||||
|
- Supabase update returns { error } object for error handling
|
||||||
|
- Use async/await with try/catch for async save operations
|
||||||
|
- Set updated_at manually with new Date().toISOString() for Supabase JSONB updates
|
||||||
|
- Clear LocalStorage draft after successful save to avoid stale drafts
|
||||||
|
- Toast state uses object with message and type for flexibility
|
||||||
|
- Loading spinner SVG with animate-spin class for visual feedback during save
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-035
|
||||||
|
- What was implemented: Export project as .vnflow file functionality
|
||||||
|
- Files changed:
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleExport with blob creation and download trigger, added projectName prop
|
||||||
|
- src/app/editor/[projectId]/page.tsx - passed projectName prop to FlowchartEditor
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Use Blob with type 'application/json' for JSON file downloads
|
||||||
|
- JSON.stringify(data, null, 2) creates pretty-printed JSON with 2-space indentation
|
||||||
|
- URL.createObjectURL creates a temporary URL for the blob
|
||||||
|
- Create temporary anchor element with download attribute to trigger file download
|
||||||
|
- Remember to cleanup: remove the anchor from DOM and revoke the object URL
|
||||||
|
- Props needed for export: pass data down from server components (e.g., projectName) to client components that need them
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-036
|
||||||
|
- What was implemented: Import project from .vnflow file functionality
|
||||||
|
- Files changed:
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleImport, handleFileSelect, validation, and confirmation dialog for unsaved changes
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Use hidden `<input type="file">` element with ref to trigger file picker programmatically via `ref.current?.click()`
|
||||||
|
- Accept multiple file extensions using comma-separated values in `accept` attribute: `accept=".vnflow,.json"`
|
||||||
|
- Reset file input value after selection (`event.target.value = ''`) to allow re-selecting the same file
|
||||||
|
- Use FileReader API with `readAsText()` for reading file contents, handle onload and onerror callbacks
|
||||||
|
- Type guard function `isValidFlowchartData()` validates imported JSON structure before loading
|
||||||
|
- Track unsaved changes by comparing current state to initialData using JSON.stringify comparison
|
||||||
|
- Show confirmation dialog before import if there are unsaved changes to prevent accidental data loss
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-037
|
||||||
|
- What was implemented: Export to Ren'Py JSON format functionality
|
||||||
|
- Files changed:
|
||||||
|
- src/components/editor/Toolbar.tsx - added 'Export to Ren'Py' button with purple styling
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented Ren'Py export types, conversion functions, and handleExportRenpy callback
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Ren'Py export uses typed interfaces for different node types: RenpyDialogueNode, RenpyMenuNode, RenpyVariableNode
|
||||||
|
- Find first node by identifying nodes with no incoming edges (not in any edge's target set)
|
||||||
|
- Use graph traversal (DFS) to organize nodes into labeled sections based on flow
|
||||||
|
- Choice nodes create branching sections - save current section before processing each branch
|
||||||
|
- Track visited nodes to detect cycles and create proper labels for jump references
|
||||||
|
- Labels are generated based on speaker name or incremental counter for uniqueness
|
||||||
|
- Replace node IDs with proper labels in a second pass after traversal completes
|
||||||
|
- Include metadata (projectName, exportedAt) at the top level of the export
|
||||||
|
- Validate JSON output with JSON.parse before download to ensure validity
|
||||||
|
- Use purple color scheme for Ren'Py-specific button to distinguish from generic export
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-038
|
||||||
|
- What was implemented: Unsaved changes warning with dirty state tracking, beforeunload, and navigation confirmation modal
|
||||||
|
- Files changed:
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - added isDirty tracking via useMemo comparing current state to lastSavedDataRef, beforeunload event handler, navigation warning modal, back button with handleBackClick, moved header from page.tsx into this component
|
||||||
|
- src/app/editor/[projectId]/page.tsx - simplified to only render FlowchartEditor (header moved to client component for dirty state access)
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Dirty state tracking uses useMemo comparing JSON.stringify of current flowchart data to a lastSavedDataRef
|
||||||
|
- lastSavedDataRef is a useRef initialized with initialData and updated after successful save
|
||||||
|
- Browser beforeunload requires both event.preventDefault() and setting event.returnValue = '' for modern browsers
|
||||||
|
- Header with back navigation was moved from server component (page.tsx) to client component (FlowchartEditor.tsx) so it can access isDirty state
|
||||||
|
- Back button uses handleBackClick which checks isDirty before navigating or showing confirmation modal
|
||||||
|
- Navigation warning modal shows "Leave Page" (red) and "Stay" buttons for clear user action
|
||||||
|
- "(unsaved changes)" indicator shown next to project name when isDirty is true
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-039
|
||||||
|
- What was implemented: Loading and error states with reusable spinner, error page, and toast retry
|
||||||
|
- Files changed:
|
||||||
|
- src/components/LoadingSpinner.tsx - new reusable loading spinner component with size variants and optional message
|
||||||
|
- src/app/editor/[projectId]/loading.tsx - updated to use LoadingSpinner component
|
||||||
|
- src/app/editor/[projectId]/page.tsx - replaced notFound() with custom error UI showing "Project Not Found" with back to dashboard link
|
||||||
|
- src/components/Toast.tsx - added optional action prop for action buttons (e.g., retry)
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated toast state type to include action, save error now shows retry button via handleSaveRef pattern
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Use a ref (handleSaveRef) to break circular dependency when a useCallback needs to reference itself for retry logic
|
||||||
|
- Toast action prop uses `{ label: string; onClick: () => void }` for flexible action buttons
|
||||||
|
- Don't auto-dismiss toasts that have action buttons (users need time to click them)
|
||||||
|
- Replace `notFound()` with inline error UI when you need custom styling and navigation links
|
||||||
|
- LoadingSpinner uses size prop ('sm' | 'md' | 'lg') for flexibility across different contexts
|
||||||
|
- Link component from next/link is needed in server components for navigation (no useRouter in server components)
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-040
|
||||||
|
- What was implemented: Conditionals on choice options - per-option visibility conditions
|
||||||
|
- Files changed:
|
||||||
|
- src/types/flowchart.ts - moved Condition type before ChoiceOption, added optional condition field to ChoiceOption
|
||||||
|
- src/components/editor/nodes/ChoiceNode.tsx - added condition button per option, condition badge display, condition editing state management
|
||||||
|
- src/components/editor/OptionConditionEditor.tsx - new modal component for editing per-option conditions (variable name, operator, value)
|
||||||
|
- src/app/editor/[projectId]/FlowchartEditor.tsx - updated Ren'Py export to include per-option conditions (option condition takes priority over edge condition)
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Per-option conditions use the same Condition type as edge conditions
|
||||||
|
- Condition type needed to be moved above ChoiceOption in types file since ChoiceOption now references it
|
||||||
|
- Use `delete obj.property` pattern instead of destructuring with unused variable to avoid lint warnings
|
||||||
|
- OptionConditionEditor is separate from ConditionEditor because it operates on option IDs vs edge IDs
|
||||||
|
- In Ren'Py export, option-level condition takes priority over edge condition since it represents visibility
|
||||||
|
- Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not
|
||||||
|
- Condition badge below option shows "if variableName operator value" text in compact format
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-041
|
||||||
|
- What was implemented: Change password for logged-in user from settings page
|
||||||
|
- Files changed:
|
||||||
|
- src/app/dashboard/settings/page.tsx - new client component with password change form (current, new, confirm fields)
|
||||||
|
- src/components/Navbar.tsx - added "Settings" link to navbar
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- Settings page lives under /dashboard/settings to reuse the dashboard layout (navbar, auth check)
|
||||||
|
- Re-authentication uses signInWithPassword with current password before allowing updateUser
|
||||||
|
- Supabase getUser() returns current user email needed for re-auth signInWithPassword call
|
||||||
|
- Password validation: check match and minimum length (6 chars) before making API calls
|
||||||
|
- Clear form fields after successful password update for security
|
||||||
|
- Settings link in navbar uses neutral zinc colors to distinguish from admin/action links
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-22 - US-042
|
||||||
|
- What was implemented: Password reset modal that automatically appears when a recovery token is detected in the URL
|
||||||
|
- Files changed:
|
||||||
|
- src/components/PasswordResetModal.tsx - new client component with modal that detects recovery tokens from URL hash, sets session, and provides password reset form
|
||||||
|
- src/app/login/page.tsx - integrated PasswordResetModal component on the login page
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- PasswordResetModal is a standalone component that can be placed on any page to detect recovery tokens
|
||||||
|
- Use window.history.replaceState to clean the URL hash after extracting the token (prevents re-triggering on refresh)
|
||||||
|
- Separate tokenError state from form error state to show different UI (expired link vs. form validation)
|
||||||
|
- Modal uses fixed positioning with z-50 to overlay above page content
|
||||||
|
- After successful password update, sign out the user and redirect to login with success message (same as reset-password page)
|
||||||
|
- The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleChangePassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('New passwords do not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setError('New password must be at least 6 characters.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
// Re-authenticate with current password
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user?.email) {
|
||||||
|
setError('Unable to verify current user.')
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||||
|
email: user.email,
|
||||||
|
password: currentPassword,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (signInError) {
|
||||||
|
setError('Current password is incorrect.')
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update to new password
|
||||||
|
const { error: updateError } = await supabase.auth.updateUser({
|
||||||
|
password: newPassword,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
setError(updateError.message)
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess('Password updated successfully.')
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
} catch {
|
||||||
|
setError('An unexpected error occurred.')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 mb-8">
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="max-w-md">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50 mb-4">
|
||||||
|
Change Password
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/30 dark:text-green-400">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="currentPassword"
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||||
|
>
|
||||||
|
Current Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="currentPassword"
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="newPassword"
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||||
|
>
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="confirmPassword"
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"
|
||||||
|
>
|
||||||
|
Confirm New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed dark:focus:ring-offset-zinc-900"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Updating...' : 'Update Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
|||||||
|
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||||
|
|
||||||
export default function EditorLoading() {
|
export default function EditorLoading() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
@@ -9,31 +11,7 @@ export default function EditorLoading() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
<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">
|
<LoadingSpinner size="lg" message="Loading editor..." />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { notFound } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import FlowchartEditor from './FlowchartEditor'
|
import FlowchartEditor from './FlowchartEditor'
|
||||||
import type { FlowchartData } from '@/types/flowchart'
|
import type { FlowchartData } from '@/types/flowchart'
|
||||||
@@ -28,24 +27,13 @@ export default async function EditorPage({ params }: PageProps) {
|
|||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error || !project) {
|
if (error || !project) {
|
||||||
notFound()
|
return (
|
||||||
}
|
<div className="flex h-screen flex-col">
|
||||||
|
<header className="flex items-center border-b border-zinc-200 bg-white px-4 py-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||||
const rawData = project.flowchart_data || {}
|
|
||||||
const flowchartData: FlowchartData = {
|
|
||||||
nodes: rawData.nodes || [],
|
|
||||||
edges: rawData.edges || [],
|
|
||||||
characters: rawData.characters || [],
|
|
||||||
variables: rawData.variables || [],
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50"
|
||||||
|
aria-label="Back to dashboard"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -60,18 +48,50 @@ export default async function EditorPage({ params }: PageProps) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
</header>
|
||||||
{project.name}
|
<div className="flex flex-1 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
|
||||||
</h1>
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<svg
|
||||||
|
className="h-12 w-12 text-red-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
Project Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-sm text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
The project you're looking for doesn't exist or you don't have access to it.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="mt-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<FlowchartEditor
|
|
||||||
projectId={project.id}
|
|
||||||
initialData={flowchartData}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowchartData = (project.flowchart_data || {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
}) as FlowchartData
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlowchartEditor
|
||||||
|
projectId={project.id}
|
||||||
|
projectName={project.name}
|
||||||
|
initialData={flowchartData}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
export default function LoginForm() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// Check for success message from password reset
|
||||||
|
const message = searchParams.get('message')
|
||||||
|
const successMessage = message === 'password_reset_success'
|
||||||
|
? 'Your password has been reset successfully. Please sign in with your new password.'
|
||||||
|
: null
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
|
WebVNWrite
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Sign in to your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||||
|
{successMessage && (
|
||||||
|
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-400">{successMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-950"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+17
-122
@@ -1,130 +1,25 @@
|
|||||||
'use client'
|
import { Suspense } from 'react'
|
||||||
|
import LoginForm from './LoginForm'
|
||||||
import { useState } from 'react'
|
import PasswordResetModal from '@/components/PasswordResetModal'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { createClient } from '@/lib/supabase/client'
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
<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">
|
<Suspense
|
||||||
<div className="text-center">
|
fallback={
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
<div className="w-full max-w-md space-y-8 text-center">
|
||||||
WebVNWrite
|
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
</h1>
|
WebVNWrite
|
||||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
</h1>
|
||||||
Sign in to your account
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
</p>
|
Loading...
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<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>
|
||||||
|
}
|
||||||
<div className="flex items-center justify-end">
|
>
|
||||||
<Link
|
<LoginForm />
|
||||||
href="/forgot-password"
|
</Suspense>
|
||||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
<PasswordResetModal />
|
||||||
>
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-62
@@ -1,65 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
redirect('/dashboard')
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,238 +1,23 @@
|
|||||||
'use client'
|
import { Suspense } from 'react'
|
||||||
|
import SignupForm from './SignupForm'
|
||||||
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 SignupPage() {
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
<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">
|
<Suspense
|
||||||
<div className="text-center">
|
fallback={
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
<div className="w-full max-w-md space-y-8 text-center">
|
||||||
WebVNWrite
|
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
</h1>
|
WebVNWrite
|
||||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
</h1>
|
||||||
Complete your account setup
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
</p>
|
Loading...
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
}
|
||||||
<button
|
>
|
||||||
type="submit"
|
<SignupForm />
|
||||||
disabled={loading}
|
</Suspense>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
type LoadingSpinnerProps = {
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-8 w-8',
|
||||||
|
lg: 'h-12 w-12',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoadingSpinner({ size = 'md', message }: LoadingSpinnerProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<svg
|
||||||
|
className={`animate-spin text-blue-500 ${sizeClasses[size]}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{message && (
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-400">{message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@ export default function Navbar({ userEmail, isAdmin }: NavbarProps) {
|
|||||||
Invite User
|
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>
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
export default function PasswordResetModal() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [tokenError, setTokenError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTokenFromUrl = async () => {
|
||||||
|
const hash = window.location.hash
|
||||||
|
if (!hash) return
|
||||||
|
|
||||||
|
const params = new URLSearchParams(hash.substring(1))
|
||||||
|
const accessToken = params.get('access_token')
|
||||||
|
const refreshToken = params.get('refresh_token')
|
||||||
|
const type = params.get('type')
|
||||||
|
|
||||||
|
if (accessToken && refreshToken && type === 'recovery') {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { error } = await supabase.auth.setSession({
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setTokenError('Invalid or expired reset link. Please request a new password reset.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear hash from URL without reloading
|
||||||
|
window.history.replaceState(null, '', window.location.pathname + window.location.search)
|
||||||
|
setIsOpen(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTokenFromUrl()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError('Password must be at least 6 characters')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase.auth.updateUser({
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
setError(updateError.message)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await supabase.auth.signOut()
|
||||||
|
|
||||||
|
setIsOpen(false)
|
||||||
|
router.push('/login?message=password_reset_success')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenError) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
Reset link expired
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
{tokenError}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setTokenError(null)}
|
||||||
|
className="mt-4 w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||||
|
Set new password
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Enter your new password below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-3 dark:bg-red-900/20">
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="reset-password"
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
New password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reset-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="reset-confirm-password"
|
||||||
|
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Confirm new password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="reset-confirm-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex w-full justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
||||||
|
>
|
||||||
|
{loading ? 'Updating password...' : 'Update password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,16 +6,23 @@ interface ToastProps {
|
|||||||
message: string
|
message: string
|
||||||
type: 'success' | 'error'
|
type: 'success' | 'error'
|
||||||
onClose: () => void
|
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(() => {
|
useEffect(() => {
|
||||||
|
// Don't auto-dismiss if there's an action button
|
||||||
|
if (action) return
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
onClose()
|
onClose()
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [onClose])
|
}, [onClose, action])
|
||||||
|
|
||||||
const bgColor =
|
const bgColor =
|
||||||
type === 'success'
|
type === 'success'
|
||||||
@@ -60,6 +67,14 @@ export default function Toast({ message, type, onClose }: ToastProps) {
|
|||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span>{message}</span>
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="ml-2 rounded p-0.5 hover:bg-white/20"
|
className="ml-2 rounded p-0.5 hover:bg-white/20"
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
|
||||||
|
|
||||||
export type ComboboxItem = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
color?: string
|
|
||||||
badge?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ComboboxProps = {
|
|
||||||
items: ComboboxItem[]
|
|
||||||
value: string | undefined
|
|
||||||
onChange: (id: string) => void
|
|
||||||
placeholder?: string
|
|
||||||
onAddNew?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Combobox({
|
|
||||||
items,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder = 'Select...',
|
|
||||||
onAddNew,
|
|
||||||
}: ComboboxProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
|
||||||
const [dropdownPosition, setDropdownPosition] = useState<'below' | 'above'>('below')
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const listRef = useRef<HTMLUListElement>(null)
|
|
||||||
|
|
||||||
const selectedItem = useMemo(
|
|
||||||
() => items.find((item) => item.id === value),
|
|
||||||
[items, value]
|
|
||||||
)
|
|
||||||
|
|
||||||
const filteredItems = useMemo(
|
|
||||||
() =>
|
|
||||||
items.filter((item) =>
|
|
||||||
item.label.toLowerCase().includes(search.toLowerCase())
|
|
||||||
),
|
|
||||||
[items, search]
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalOptions = filteredItems.length + (onAddNew ? 1 : 0)
|
|
||||||
|
|
||||||
const updateDropdownPosition = useCallback(() => {
|
|
||||||
if (!containerRef.current) return
|
|
||||||
const rect = containerRef.current.getBoundingClientRect()
|
|
||||||
const spaceBelow = window.innerHeight - rect.bottom
|
|
||||||
const spaceAbove = rect.top
|
|
||||||
setDropdownPosition(spaceBelow < 200 && spaceAbove > spaceBelow ? 'above' : 'below')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const open = useCallback(() => {
|
|
||||||
setIsOpen(true)
|
|
||||||
setSearch('')
|
|
||||||
setHighlightedIndex(0)
|
|
||||||
updateDropdownPosition()
|
|
||||||
}, [updateDropdownPosition])
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
|
||||||
setIsOpen(false)
|
|
||||||
setSearch('')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const selectItem = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
onChange(id)
|
|
||||||
close()
|
|
||||||
},
|
|
||||||
[onChange, close]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return
|
|
||||||
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
}, [isOpen, close])
|
|
||||||
|
|
||||||
// Scroll highlighted item into view
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen || !listRef.current) return
|
|
||||||
const items = listRef.current.querySelectorAll('[data-combobox-item]')
|
|
||||||
const highlighted = items[highlightedIndex]
|
|
||||||
if (highlighted) {
|
|
||||||
highlighted.scrollIntoView({ block: 'nearest' })
|
|
||||||
}
|
|
||||||
}, [highlightedIndex, isOpen])
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
open()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault()
|
|
||||||
setHighlightedIndex((prev) => (prev + 1) % totalOptions)
|
|
||||||
break
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault()
|
|
||||||
setHighlightedIndex((prev) => (prev - 1 + totalOptions) % totalOptions)
|
|
||||||
break
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault()
|
|
||||||
if (highlightedIndex < filteredItems.length) {
|
|
||||||
selectItem(filteredItems[highlightedIndex].id)
|
|
||||||
} else if (onAddNew && highlightedIndex === filteredItems.length) {
|
|
||||||
onAddNew()
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault()
|
|
||||||
close()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isOpen, open, close, highlightedIndex, filteredItems, totalOptions, selectItem, onAddNew]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="relative w-full">
|
|
||||||
<div
|
|
||||||
className="flex w-full cursor-pointer items-center rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-white"
|
|
||||||
onClick={() => {
|
|
||||||
if (isOpen) {
|
|
||||||
close()
|
|
||||||
} else {
|
|
||||||
open()
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isOpen ? (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value)
|
|
||||||
setHighlightedIndex(0)
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="w-full bg-transparent outline-none placeholder-zinc-400 dark:placeholder-zinc-500"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className={selectedItem ? '' : 'text-zinc-400 dark:text-zinc-500'}>
|
|
||||||
{selectedItem ? (
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
{selectedItem.color && (
|
|
||||||
<span
|
|
||||||
className="inline-block h-3 w-3 rounded-full border border-zinc-300 dark:border-zinc-600"
|
|
||||||
style={{ backgroundColor: selectedItem.color }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{selectedItem.badge && (
|
|
||||||
<span className="rounded bg-zinc-200 px-1 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
|
||||||
{selectedItem.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{selectedItem.label}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
placeholder
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<svg
|
|
||||||
className={`ml-auto h-4 w-4 shrink-0 text-zinc-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<ul
|
|
||||||
ref={listRef}
|
|
||||||
className={`absolute z-50 max-h-48 w-full overflow-auto rounded border border-zinc-300 bg-white shadow-lg dark:border-zinc-600 dark:bg-zinc-800 ${
|
|
||||||
dropdownPosition === 'above' ? 'bottom-full mb-1' : 'top-full mt-1'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{filteredItems.length === 0 && !onAddNew && (
|
|
||||||
<li className="px-2 py-1.5 text-sm text-zinc-400 dark:text-zinc-500">
|
|
||||||
No results found
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredItems.map((item, index) => (
|
|
||||||
<li
|
|
||||||
key={item.id}
|
|
||||||
data-combobox-item
|
|
||||||
className={`flex cursor-pointer items-center gap-1.5 px-2 py-1.5 text-sm ${
|
|
||||||
highlightedIndex === index
|
|
||||||
? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-100'
|
|
||||||
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-700'
|
|
||||||
} ${item.id === value ? 'font-medium' : ''}`}
|
|
||||||
onClick={() => selectItem(item.id)}
|
|
||||||
onMouseEnter={() => setHighlightedIndex(index)}
|
|
||||||
>
|
|
||||||
{item.color && (
|
|
||||||
<span
|
|
||||||
className="inline-block h-3 w-3 shrink-0 rounded-full border border-zinc-300 dark:border-zinc-600"
|
|
||||||
style={{ backgroundColor: item.color }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{item.badge && (
|
|
||||||
<span className="shrink-0 rounded bg-zinc-200 px-1 py-0.5 text-xs font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
|
||||||
{item.badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="truncate">{item.label}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{onAddNew && (
|
|
||||||
<li
|
|
||||||
data-combobox-item
|
|
||||||
className={`flex cursor-pointer items-center gap-1.5 border-t border-zinc-200 px-2 py-1.5 text-sm dark:border-zinc-700 ${
|
|
||||||
highlightedIndex === filteredItems.length
|
|
||||||
? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-100'
|
|
||||||
: 'text-blue-600 hover:bg-zinc-100 dark:text-blue-400 dark:hover:bg-zinc-700'
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
|
||||||
onAddNew()
|
|
||||||
close()
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setHighlightedIndex(filteredItems.length)}
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
<span>Add new...</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import type { Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
|
type ConditionEditorProps = {
|
||||||
|
edgeId: string
|
||||||
|
condition?: Condition
|
||||||
|
onSave: (edgeId: string, condition: Condition) => void
|
||||||
|
onRemove: (edgeId: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
|
||||||
|
|
||||||
|
export default function ConditionEditor({
|
||||||
|
edgeId,
|
||||||
|
condition,
|
||||||
|
onSave,
|
||||||
|
onRemove,
|
||||||
|
onCancel,
|
||||||
|
}: ConditionEditorProps) {
|
||||||
|
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
|
||||||
|
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
|
||||||
|
const [value, setValue] = useState(condition?.value ?? 0)
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onCancel])
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!variableName.trim()) return
|
||||||
|
onSave(edgeId, {
|
||||||
|
variableName: variableName.trim(),
|
||||||
|
operator,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}, [edgeId, variableName, operator, value, onSave])
|
||||||
|
|
||||||
|
const handleRemove = useCallback(() => {
|
||||||
|
onRemove(edgeId)
|
||||||
|
}, [edgeId, onRemove])
|
||||||
|
|
||||||
|
const hasExistingCondition = !!condition
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="mb-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{hasExistingCondition ? 'Edit Condition' : 'Add Condition'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Variable Name Input */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="variableName"
|
||||||
|
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Variable Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="variableName"
|
||||||
|
value={variableName}
|
||||||
|
onChange={(e) => setVariableName(e.target.value)}
|
||||||
|
placeholder="e.g., score, health, affection"
|
||||||
|
autoFocus
|
||||||
|
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operator Dropdown */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="operator"
|
||||||
|
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Operator
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="operator"
|
||||||
|
value={operator}
|
||||||
|
onChange={(e) => setOperator(e.target.value as Condition['operator'])}
|
||||||
|
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
|
||||||
|
>
|
||||||
|
{OPERATORS.map((op) => (
|
||||||
|
<option key={op} value={op}>
|
||||||
|
{op === '==' ? '== (equals)' : op === '!=' ? '!= (not equals)' : op === '>' ? '> (greater than)' : op === '<' ? '< (less than)' : op === '>=' ? '>= (greater or equal)' : '<= (less or equal)'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value Number Input */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="value"
|
||||||
|
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Value
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="value"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{variableName.trim() && (
|
||||||
|
<div className="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-700">
|
||||||
|
<span className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
Condition: <code className="font-mono text-blue-600 dark:text-blue-400">{variableName.trim()} {operator} {value}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="mt-6 flex justify-between">
|
||||||
|
<div>
|
||||||
|
{hasExistingCondition && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemove}
|
||||||
|
className="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!variableName.trim()}
|
||||||
|
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import type { Condition } from '@/types/flowchart'
|
||||||
|
|
||||||
|
type OptionConditionEditorProps = {
|
||||||
|
optionId: string
|
||||||
|
optionLabel: string
|
||||||
|
condition?: Condition
|
||||||
|
onSave: (optionId: string, condition: Condition) => void
|
||||||
|
onRemove: (optionId: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!=']
|
||||||
|
|
||||||
|
export default function OptionConditionEditor({
|
||||||
|
optionId,
|
||||||
|
optionLabel,
|
||||||
|
condition,
|
||||||
|
onSave,
|
||||||
|
onRemove,
|
||||||
|
onCancel,
|
||||||
|
}: OptionConditionEditorProps) {
|
||||||
|
const [variableName, setVariableName] = useState(condition?.variableName ?? '')
|
||||||
|
const [operator, setOperator] = useState<Condition['operator']>(condition?.operator ?? '==')
|
||||||
|
const [value, setValue] = useState(condition?.value ?? 0)
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onCancel])
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!variableName.trim()) return
|
||||||
|
onSave(optionId, {
|
||||||
|
variableName: variableName.trim(),
|
||||||
|
operator,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}, [optionId, variableName, operator, value, onSave])
|
||||||
|
|
||||||
|
const handleRemove = useCallback(() => {
|
||||||
|
onRemove(optionId)
|
||||||
|
}, [optionId, onRemove])
|
||||||
|
|
||||||
|
const hasExistingCondition = !!condition
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="mb-1 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{hasExistingCondition ? 'Edit Option Condition' : 'Add Option Condition'}
|
||||||
|
</h3>
|
||||||
|
<p className="mb-4 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
Option: {optionLabel || '(unnamed)'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Variable Name Input */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="optionVariableName"
|
||||||
|
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Variable Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="optionVariableName"
|
||||||
|
value={variableName}
|
||||||
|
onChange={(e) => setVariableName(e.target.value)}
|
||||||
|
placeholder="e.g., affection, score, health"
|
||||||
|
autoFocus
|
||||||
|
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Operator Dropdown */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="optionOperator"
|
||||||
|
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Operator
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="optionOperator"
|
||||||
|
value={operator}
|
||||||
|
onChange={(e) => setOperator(e.target.value as Condition['operator'])}
|
||||||
|
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
|
||||||
|
>
|
||||||
|
{OPERATORS.map((op) => (
|
||||||
|
<option key={op} value={op}>
|
||||||
|
{op === '==' ? '== (equals)' : op === '!=' ? '!= (not equals)' : op === '>' ? '> (greater than)' : op === '<' ? '< (less than)' : op === '>=' ? '>= (greater or equal)' : '<= (less or equal)'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value Number Input */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="optionValue"
|
||||||
|
className="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
Value
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="optionValue"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{variableName.trim() && (
|
||||||
|
<div className="rounded-md border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-600 dark:bg-zinc-700">
|
||||||
|
<span className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
|
Show option when: <code className="font-mono text-amber-600 dark:text-amber-400">{variableName.trim()} {operator} {value}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="mt-6 flex justify-between">
|
||||||
|
<div>
|
||||||
|
{hasExistingCondition && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemove}
|
||||||
|
className="rounded-md border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!variableName.trim()}
|
||||||
|
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,717 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { nanoid } from 'nanoid'
|
|
||||||
import type { Character, Variable } from '@/types/flowchart'
|
|
||||||
|
|
||||||
type Tab = 'characters' | 'variables'
|
|
||||||
|
|
||||||
type ProjectSettingsModalProps = {
|
|
||||||
characters: Character[]
|
|
||||||
variables: Variable[]
|
|
||||||
onCharactersChange: (characters: Character[]) => void
|
|
||||||
onVariablesChange: (variables: Variable[]) => void
|
|
||||||
onClose: () => void
|
|
||||||
getCharacterUsageCount: (characterId: string) => number
|
|
||||||
getVariableUsageCount: (variableId: string) => number
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomHexColor(): string {
|
|
||||||
const colors = [
|
|
||||||
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e',
|
|
||||||
'#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7',
|
|
||||||
'#ec4899', '#f43f5e',
|
|
||||||
]
|
|
||||||
return colors[Math.floor(Math.random() * colors.length)]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectSettingsModal({
|
|
||||||
characters,
|
|
||||||
variables,
|
|
||||||
onCharactersChange,
|
|
||||||
onVariablesChange,
|
|
||||||
onClose,
|
|
||||||
getCharacterUsageCount,
|
|
||||||
getVariableUsageCount,
|
|
||||||
}: ProjectSettingsModalProps) {
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('characters')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-black/50"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div className="relative flex h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-white shadow-xl dark:bg-zinc-800">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
|
||||||
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
|
||||||
Project Settings
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
|
|
||||||
>
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex border-b border-zinc-200 px-6 dark:border-zinc-700">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('characters')}
|
|
||||||
className={`border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === 'characters'
|
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
||||||
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Characters
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('variables')}
|
|
||||||
className={`border-b-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === 'variables'
|
|
||||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
|
||||||
: 'border-transparent text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Variables
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
||||||
{activeTab === 'characters' && (
|
|
||||||
<CharactersTab
|
|
||||||
characters={characters}
|
|
||||||
onChange={onCharactersChange}
|
|
||||||
getUsageCount={getCharacterUsageCount}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeTab === 'variables' && (
|
|
||||||
<VariablesTab
|
|
||||||
variables={variables}
|
|
||||||
onChange={onVariablesChange}
|
|
||||||
getUsageCount={getVariableUsageCount}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Characters Tab
|
|
||||||
type CharactersTabProps = {
|
|
||||||
characters: Character[]
|
|
||||||
onChange: (characters: Character[]) => void
|
|
||||||
getUsageCount: (characterId: string) => number
|
|
||||||
}
|
|
||||||
|
|
||||||
type CharacterFormData = {
|
|
||||||
name: string
|
|
||||||
color: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function CharactersTab({ characters, onChange, getUsageCount }: CharactersTabProps) {
|
|
||||||
const [isAdding, setIsAdding] = useState(false)
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
const [formData, setFormData] = useState<CharacterFormData>({ name: '', color: randomHexColor(), description: '' })
|
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setFormData({ name: '', color: randomHexColor(), description: '' })
|
|
||||||
setFormError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateName = (name: string, excludeId?: string): boolean => {
|
|
||||||
if (!name.trim()) {
|
|
||||||
setFormError('Name is required')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const duplicate = characters.find(
|
|
||||||
(c) => c.name.toLowerCase() === name.trim().toLowerCase() && c.id !== excludeId
|
|
||||||
)
|
|
||||||
if (duplicate) {
|
|
||||||
setFormError('A character with this name already exists')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
setFormError(null)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
if (!validateName(formData.name)) return
|
|
||||||
const newCharacter: Character = {
|
|
||||||
id: nanoid(),
|
|
||||||
name: formData.name.trim(),
|
|
||||||
color: formData.color,
|
|
||||||
description: formData.description.trim() || undefined,
|
|
||||||
}
|
|
||||||
onChange([...characters, newCharacter])
|
|
||||||
setIsAdding(false)
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEdit = (character: Character) => {
|
|
||||||
setEditingId(character.id)
|
|
||||||
setFormData({
|
|
||||||
name: character.name,
|
|
||||||
color: character.color,
|
|
||||||
description: character.description || '',
|
|
||||||
})
|
|
||||||
setFormError(null)
|
|
||||||
setIsAdding(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveEdit = () => {
|
|
||||||
if (!editingId) return
|
|
||||||
if (!validateName(formData.name, editingId)) return
|
|
||||||
onChange(
|
|
||||||
characters.map((c) =>
|
|
||||||
c.id === editingId
|
|
||||||
? { ...c, name: formData.name.trim(), color: formData.color, description: formData.description.trim() || undefined }
|
|
||||||
: c
|
|
||||||
)
|
|
||||||
)
|
|
||||||
setEditingId(null)
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
|
||||||
const usageCount = getUsageCount(id)
|
|
||||||
if (usageCount > 0 && deleteConfirm !== id) {
|
|
||||||
setDeleteConfirm(id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onChange(characters.filter((c) => c.id !== id))
|
|
||||||
setDeleteConfirm(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancelForm = () => {
|
|
||||||
setIsAdding(false)
|
|
||||||
setEditingId(null)
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
|
||||||
Define characters that can be referenced in dialogue nodes.
|
|
||||||
</p>
|
|
||||||
{!isAdding && !editingId && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
|
||||||
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
|
||||||
>
|
|
||||||
Add Character
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Character List */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{characters.map((character) => (
|
|
||||||
<div key={character.id}>
|
|
||||||
{editingId === character.id ? (
|
|
||||||
<CharacterForm
|
|
||||||
formData={formData}
|
|
||||||
formError={formError}
|
|
||||||
onChange={setFormData}
|
|
||||||
onSave={handleSaveEdit}
|
|
||||||
onCancel={handleCancelForm}
|
|
||||||
saveLabel="Save"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-between rounded-lg border border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="h-4 w-4 rounded-full"
|
|
||||||
style={{ backgroundColor: character.color }}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
|
|
||||||
{character.name}
|
|
||||||
</span>
|
|
||||||
{character.description && (
|
|
||||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
||||||
{character.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{deleteConfirm === character.id && (
|
|
||||||
<span className="mr-2 text-xs text-orange-600 dark:text-orange-400">
|
|
||||||
Used in {getUsageCount(character.id)} node(s). Delete anyway?
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(character)}
|
|
||||||
className="rounded px-2 py-1 text-sm text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(character.id)}
|
|
||||||
className={`rounded px-2 py-1 text-sm ${
|
|
||||||
deleteConfirm === character.id
|
|
||||||
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20'
|
|
||||||
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{characters.length === 0 && !isAdding && (
|
|
||||||
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
|
||||||
No characters defined yet. Click "Add Character" to create one.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Form */}
|
|
||||||
{isAdding && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<CharacterForm
|
|
||||||
formData={formData}
|
|
||||||
formError={formError}
|
|
||||||
onChange={setFormData}
|
|
||||||
onSave={handleAdd}
|
|
||||||
onCancel={handleCancelForm}
|
|
||||||
saveLabel="Add"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Character Form
|
|
||||||
type CharacterFormProps = {
|
|
||||||
formData: CharacterFormData
|
|
||||||
formError: string | null
|
|
||||||
onChange: (data: CharacterFormData) => void
|
|
||||||
onSave: () => void
|
|
||||||
onCancel: () => void
|
|
||||||
saveLabel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function CharacterForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: CharacterFormProps) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
||||||
Color
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={formData.color}
|
|
||||||
onChange={(e) => onChange({ ...formData, color: e.target.value })}
|
|
||||||
className="h-8 w-8 cursor-pointer rounded border border-zinc-300 dark:border-zinc-600"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
||||||
Name *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => onChange({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="Character name"
|
|
||||||
autoFocus
|
|
||||||
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => onChange({ ...formData, description: e.target.value })}
|
|
||||||
placeholder="Optional description"
|
|
||||||
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{formError && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 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
|
|
||||||
onClick={onSave}
|
|
||||||
className="rounded-md bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
|
||||||
>
|
|
||||||
{saveLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variables Tab
|
|
||||||
type VariablesTabProps = {
|
|
||||||
variables: Variable[]
|
|
||||||
onChange: (variables: Variable[]) => void
|
|
||||||
getUsageCount: (variableId: string) => number
|
|
||||||
}
|
|
||||||
|
|
||||||
type VariableType = 'numeric' | 'string' | 'boolean'
|
|
||||||
|
|
||||||
type VariableFormData = {
|
|
||||||
name: string
|
|
||||||
type: VariableType
|
|
||||||
initialValue: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultInitialValues: Record<VariableType, string> = {
|
|
||||||
numeric: '0',
|
|
||||||
string: '',
|
|
||||||
boolean: 'false',
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInitialValue(type: VariableType, raw: string): number | string | boolean {
|
|
||||||
switch (type) {
|
|
||||||
case 'numeric':
|
|
||||||
return Number(raw) || 0
|
|
||||||
case 'boolean':
|
|
||||||
return raw === 'true'
|
|
||||||
case 'string':
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function VariablesTab({ variables, onChange, getUsageCount }: VariablesTabProps) {
|
|
||||||
const [isAdding, setIsAdding] = useState(false)
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
const [formData, setFormData] = useState<VariableFormData>({ name: '', type: 'numeric', initialValue: '0', description: '' })
|
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setFormData({ name: '', type: 'numeric', initialValue: '0', description: '' })
|
|
||||||
setFormError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateName = (name: string, excludeId?: string): boolean => {
|
|
||||||
if (!name.trim()) {
|
|
||||||
setFormError('Name is required')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const duplicate = variables.find(
|
|
||||||
(v) => v.name.toLowerCase() === name.trim().toLowerCase() && v.id !== excludeId
|
|
||||||
)
|
|
||||||
if (duplicate) {
|
|
||||||
setFormError('A variable with this name already exists')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
setFormError(null)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
if (!validateName(formData.name)) return
|
|
||||||
const newVariable: Variable = {
|
|
||||||
id: nanoid(),
|
|
||||||
name: formData.name.trim(),
|
|
||||||
type: formData.type,
|
|
||||||
initialValue: parseInitialValue(formData.type, formData.initialValue),
|
|
||||||
description: formData.description.trim() || undefined,
|
|
||||||
}
|
|
||||||
onChange([...variables, newVariable])
|
|
||||||
setIsAdding(false)
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEdit = (variable: Variable) => {
|
|
||||||
setEditingId(variable.id)
|
|
||||||
setFormData({
|
|
||||||
name: variable.name,
|
|
||||||
type: variable.type,
|
|
||||||
initialValue: String(variable.initialValue),
|
|
||||||
description: variable.description || '',
|
|
||||||
})
|
|
||||||
setFormError(null)
|
|
||||||
setIsAdding(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveEdit = () => {
|
|
||||||
if (!editingId) return
|
|
||||||
if (!validateName(formData.name, editingId)) return
|
|
||||||
onChange(
|
|
||||||
variables.map((v) =>
|
|
||||||
v.id === editingId
|
|
||||||
? {
|
|
||||||
...v,
|
|
||||||
name: formData.name.trim(),
|
|
||||||
type: formData.type,
|
|
||||||
initialValue: parseInitialValue(formData.type, formData.initialValue),
|
|
||||||
description: formData.description.trim() || undefined,
|
|
||||||
}
|
|
||||||
: v
|
|
||||||
)
|
|
||||||
)
|
|
||||||
setEditingId(null)
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
|
||||||
const usageCount = getUsageCount(id)
|
|
||||||
if (usageCount > 0 && deleteConfirm !== id) {
|
|
||||||
setDeleteConfirm(id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onChange(variables.filter((v) => v.id !== id))
|
|
||||||
setDeleteConfirm(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancelForm = () => {
|
|
||||||
setIsAdding(false)
|
|
||||||
setEditingId(null)
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
|
||||||
Define variables that can be referenced in variable nodes and edge conditions.
|
|
||||||
</p>
|
|
||||||
{!isAdding && !editingId && (
|
|
||||||
<button
|
|
||||||
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
|
||||||
className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
|
||||||
>
|
|
||||||
Add Variable
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Variable List */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{variables.map((variable) => (
|
|
||||||
<div key={variable.id}>
|
|
||||||
{editingId === variable.id ? (
|
|
||||||
<VariableForm
|
|
||||||
formData={formData}
|
|
||||||
formError={formError}
|
|
||||||
onChange={setFormData}
|
|
||||||
onSave={handleSaveEdit}
|
|
||||||
onCancel={handleCancelForm}
|
|
||||||
saveLabel="Save"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-between rounded-lg border border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
|
||||||
variable.type === 'numeric'
|
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
|
||||||
: variable.type === 'string'
|
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
|
||||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
|
|
||||||
}`}>
|
|
||||||
{variable.type}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
|
|
||||||
{variable.name}
|
|
||||||
</span>
|
|
||||||
{variable.description && (
|
|
||||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
||||||
{variable.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
|
||||||
Initial: {String(variable.initialValue)}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{deleteConfirm === variable.id && (
|
|
||||||
<span className="mr-2 text-xs text-orange-600 dark:text-orange-400">
|
|
||||||
Used in {getUsageCount(variable.id)} node(s). Delete anyway?
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(variable)}
|
|
||||||
className="rounded px-2 py-1 text-sm text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(variable.id)}
|
|
||||||
className={`rounded px-2 py-1 text-sm ${
|
|
||||||
deleteConfirm === variable.id
|
|
||||||
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20'
|
|
||||||
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{variables.length === 0 && !isAdding && (
|
|
||||||
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
|
||||||
No variables defined yet. Click "Add Variable" to create one.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Form */}
|
|
||||||
{isAdding && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<VariableForm
|
|
||||||
formData={formData}
|
|
||||||
formError={formError}
|
|
||||||
onChange={setFormData}
|
|
||||||
onSave={handleAdd}
|
|
||||||
onCancel={handleCancelForm}
|
|
||||||
saveLabel="Add"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variable Form
|
|
||||||
type VariableFormProps = {
|
|
||||||
formData: VariableFormData
|
|
||||||
formError: string | null
|
|
||||||
onChange: (data: VariableFormData) => void
|
|
||||||
onSave: () => void
|
|
||||||
onCancel: () => void
|
|
||||||
saveLabel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function VariableForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: VariableFormProps) {
|
|
||||||
const handleTypeChange = (newType: VariableType) => {
|
|
||||||
onChange({ ...formData, type: newType, initialValue: defaultInitialValues[newType] })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
||||||
Name *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => onChange({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="Variable name"
|
|
||||||
autoFocus
|
|
||||||
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
||||||
Type *
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.type}
|
|
||||||
onChange={(e) => handleTypeChange(e.target.value as VariableType)}
|
|
||||||
className="block rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
<option value="numeric">Numeric</option>
|
|
||||||
<option value="string">String</option>
|
|
||||||
<option value="boolean">Boolean</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
||||||
Initial Value *
|
|
||||||
</label>
|
|
||||||
{formData.type === 'boolean' ? (
|
|
||||||
<select
|
|
||||||
value={formData.initialValue}
|
|
||||||
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
|
|
||||||
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
<option value="false">false</option>
|
|
||||||
<option value="true">true</option>
|
|
||||||
</select>
|
|
||||||
) : formData.type === 'numeric' ? (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData.initialValue}
|
|
||||||
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
|
|
||||||
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.initialValue}
|
|
||||||
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
|
|
||||||
placeholder="Initial value"
|
|
||||||
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => onChange({ ...formData, description: e.target.value })}
|
|
||||||
placeholder="Optional description"
|
|
||||||
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 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-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{formError && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 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
|
|
||||||
onClick={onSave}
|
|
||||||
className="rounded-md bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
|
|
||||||
>
|
|
||||||
{saveLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,9 @@ type ToolbarProps = {
|
|||||||
onAddVariable: () => void
|
onAddVariable: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
|
onExportRenpy: () => void
|
||||||
onImport: () => void
|
onImport: () => void
|
||||||
onProjectSettings: () => void
|
isSaving?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Toolbar({
|
export default function Toolbar({
|
||||||
@@ -16,8 +17,9 @@ export default function Toolbar({
|
|||||||
onAddVariable,
|
onAddVariable,
|
||||||
onSave,
|
onSave,
|
||||||
onExport,
|
onExport,
|
||||||
|
onExportRenpy,
|
||||||
onImport,
|
onImport,
|
||||||
onProjectSettings,
|
isSaving = false,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
<div className="flex items-center justify-between border-b border-zinc-200 bg-zinc-50 px-4 py-2 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
@@ -46,17 +48,34 @@ export default function Toolbar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
|
||||||
onClick={onProjectSettings}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Project Settings
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={onSave}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={onExport}
|
onClick={onExport}
|
||||||
@@ -64,6 +83,12 @@ export default function Toolbar({
|
|||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</button>
|
</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'Py
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onImport}
|
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"
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, ChangeEvent } from 'react'
|
import { useCallback, useState, 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 = {
|
||||||
@@ -19,6 +22,7 @@ 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>) => {
|
||||||
@@ -96,6 +100,57 @@ 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
|
||||||
@@ -119,32 +174,61 @@ 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} className="relative flex items-center gap-1">
|
<div key={option.id}>
|
||||||
<input
|
<div className="relative flex items-center gap-1">
|
||||||
type="text"
|
<input
|
||||||
value={option.label}
|
type="text"
|
||||||
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
value={option.label}
|
||||||
placeholder={`Option ${index + 1}`}
|
onChange={(e) => updateOptionLabel(option.id, e.target.value)}
|
||||||
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"
|
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"
|
<button
|
||||||
onClick={() => removeOption(option.id)}
|
type="button"
|
||||||
disabled={data.options.length <= MIN_OPTIONS}
|
onClick={() => setEditingConditionOptionId(option.id)}
|
||||||
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"
|
className={`flex h-6 w-6 items-center justify-center rounded text-xs ${
|
||||||
title="Remove option"
|
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'
|
||||||
</button>
|
}`}
|
||||||
<Handle
|
title={option.condition ? `Condition: ${option.condition.variableName} ${option.condition.operator} ${option.condition.value}` : 'Add condition'}
|
||||||
type="source"
|
>
|
||||||
position={Position.Bottom}
|
{option.condition ? (
|
||||||
id={`option-${index}`}
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
className="!h-3 !w-3 !border-2 !border-green-500 !bg-white dark:!bg-zinc-800"
|
<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" />
|
||||||
style={{
|
</svg>
|
||||||
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>
|
||||||
@@ -158,6 +242,17 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-40
@@ -4,41 +4,29 @@ export type Position = {
|
|||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Character type: represents a defined character in the project
|
// Condition type for conditional edges and choice options
|
||||||
export type Character = {
|
export type Condition = {
|
||||||
id: string;
|
variableName: string;
|
||||||
name: string;
|
operator: '>' | '<' | '==' | '>=' | '<=' | '!=';
|
||||||
color: string; // hex color
|
value: number;
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Variable type: represents a defined variable in the project
|
|
||||||
export type Variable = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: 'numeric' | 'string' | 'boolean';
|
|
||||||
initialValue: number | string | boolean;
|
|
||||||
description?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// DialogueNode type: represents character speech/dialogue
|
// DialogueNode type: represents character speech/dialogue
|
||||||
export type DialogueNodeData = {
|
|
||||||
speaker?: string;
|
|
||||||
characterId?: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DialogueNode = {
|
export type DialogueNode = {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'dialogue';
|
type: 'dialogue';
|
||||||
position: Position;
|
position: Position;
|
||||||
data: DialogueNodeData;
|
data: {
|
||||||
|
speaker?: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Choice option type for ChoiceNode
|
// Choice option type for ChoiceNode
|
||||||
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
|
||||||
@@ -53,31 +41,20 @@ export type ChoiceNode = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// VariableNode type: represents variable operations
|
// VariableNode type: represents variable operations
|
||||||
export type VariableNodeData = {
|
|
||||||
variableName: string;
|
|
||||||
variableId?: string;
|
|
||||||
operation: 'set' | 'add' | 'subtract';
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VariableNode = {
|
export type VariableNode = {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'variable';
|
type: 'variable';
|
||||||
position: Position;
|
position: Position;
|
||||||
data: VariableNodeData;
|
data: {
|
||||||
|
variableName: string;
|
||||||
|
operation: 'set' | 'add' | 'subtract';
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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;
|
|
||||||
variableId?: 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;
|
||||||
@@ -94,6 +71,4 @@ export type FlowchartEdge = {
|
|||||||
export type FlowchartData = {
|
export type FlowchartData = {
|
||||||
nodes: FlowchartNode[];
|
nodes: FlowchartNode[];
|
||||||
edges: FlowchartEdge[];
|
edges: FlowchartEdge[];
|
||||||
characters: Character[];
|
|
||||||
variables: Variable[];
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
-- Migration: Add characters and variables arrays to flowchart_data JSONB default
|
|
||||||
-- Part of Character/Variable Management feature (US-055)
|
|
||||||
--
|
|
||||||
-- The characters and variables arrays are stored within the flowchart_data JSONB column.
|
|
||||||
-- This migration updates the default value for new projects to include empty arrays.
|
|
||||||
-- Existing projects without these fields are handled at the application layer,
|
|
||||||
-- which defaults missing characters/variables to empty arrays on read.
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- UPDATE DEFAULT VALUE FOR flowchart_data
|
|
||||||
-- =============================================================================
|
|
||||||
-- Update the default to include characters and variables arrays
|
|
||||||
ALTER TABLE projects
|
|
||||||
ALTER COLUMN flowchart_data
|
|
||||||
SET DEFAULT '{"nodes": [], "edges": [], "characters": [], "variables": []}'::jsonb;
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- DOCUMENTATION: JSONB Structure
|
|
||||||
-- =============================================================================
|
|
||||||
-- The flowchart_data column now expects the following top-level structure:
|
|
||||||
--
|
|
||||||
-- {
|
|
||||||
-- "nodes": [...], -- Array of flowchart nodes
|
|
||||||
-- "edges": [...], -- Array of flowchart edges
|
|
||||||
-- "characters": [ -- Array of character definitions
|
|
||||||
-- {
|
|
||||||
-- "id": "string", -- Unique identifier (nanoid)
|
|
||||||
-- "name": "string", -- Character name (required, unique per project)
|
|
||||||
-- "color": "string", -- Hex color code (e.g., "#FF5733")
|
|
||||||
-- "description": "string" -- Optional description
|
|
||||||
-- }
|
|
||||||
-- ],
|
|
||||||
-- "variables": [ -- Array of variable definitions
|
|
||||||
-- {
|
|
||||||
-- "id": "string", -- Unique identifier (nanoid)
|
|
||||||
-- "name": "string", -- Variable name (required, unique per project)
|
|
||||||
-- "type": "string", -- One of: 'numeric', 'string', 'boolean'
|
|
||||||
-- "initialValue": "any", -- Initial value matching the type
|
|
||||||
-- "description": "string" -- Optional description
|
|
||||||
-- }
|
|
||||||
-- ]
|
|
||||||
-- }
|
|
||||||
--
|
|
||||||
-- Note: Existing projects that do not have characters/variables fields
|
|
||||||
-- are handled at the application layer, which defaults them to empty arrays.
|
|
||||||
Reference in New Issue
Block a user