Compare commits
18 Commits
15543b8d31
...
4d0ee8e578
| Author | SHA1 | Date |
|---|---|---|
|
|
4d0ee8e578 | |
|
|
add18ee10a | |
|
|
2159414804 | |
|
|
4d3f288784 | |
|
|
9e03a2b9b3 | |
|
|
87653b86cb | |
|
|
72a66ba39c | |
|
|
10ac9fe1e0 | |
|
|
5b87af6244 | |
|
|
be4ecc482e | |
|
|
a152383c91 | |
|
|
0f8a9546b5 | |
|
|
fae8cd7764 | |
|
|
bcee0acceb | |
|
|
131d1b272d | |
|
|
5907d86467 | |
|
|
8eb7a9416b | |
|
|
3e63864702 |
|
|
@ -1,3 +1,7 @@
|
|||
# Supabase Configuration
|
||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
|
||||
|
||||
# Site URL (used for redirect URLs in emails)
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
|
|
|
|||
30
prd.json
30
prd.json
|
|
@ -35,7 +35,7 @@
|
|||
"Typecheck passes"
|
||||
],
|
||||
"priority": 2,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
"Typecheck passes"
|
||||
],
|
||||
"priority": 3,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
"Typecheck passes"
|
||||
],
|
||||
"priority": 4,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
"Typecheck passes"
|
||||
],
|
||||
"priority": 5,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -102,7 +102,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 6,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 7,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -136,7 +136,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 8,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -154,7 +154,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 9,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -173,7 +173,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 10,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -191,7 +191,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 11,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -210,7 +210,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 12,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -227,7 +227,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 13,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -244,7 +244,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 14,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -261,7 +261,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 15,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
@ -280,7 +280,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 16,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
|
|||
234
progress.txt
234
progress.txt
|
|
@ -3,6 +3,20 @@
|
|||
- Source files are in `src/` directory (app, components, lib, types)
|
||||
- Supabase is configured with @supabase/supabase-js and @supabase/ssr packages
|
||||
- Environment variables follow NEXT_PUBLIC_* convention for client-side access
|
||||
- Use `npm run typecheck` to run TypeScript type checking (tsc --noEmit)
|
||||
- Flowchart types exported from `src/types/flowchart.ts`
|
||||
- Supabase migrations go in `supabase/migrations/` with timestamp prefix (YYYYMMDDHHMMSS_*.sql)
|
||||
- Database has profiles table (linked to auth.users) and projects table (with flowchart_data JSONB)
|
||||
- RLS policies enforce user_id = auth.uid() for project access
|
||||
- Supabase client utilities in `src/lib/supabase/`: client.ts (browser), server.ts (App Router), middleware.ts (route protection)
|
||||
- Next.js middleware.ts at project root handles route protection using updateSession helper
|
||||
- Public auth routes: /login, /signup, /forgot-password, /reset-password
|
||||
- Protected routes: /dashboard, /editor/* (redirect to /login if unauthenticated)
|
||||
- Auth pages use 'use client' with useState, createClient() from lib/supabase/client.ts, and useRouter for redirects
|
||||
- For lists with client-side updates (delete/add), use wrapper client component that receives initialData from server component
|
||||
- Toast component in `src/components/Toast.tsx` for success/error notifications (auto-dismiss after 3s)
|
||||
- Admin operations use SUPABASE_SERVICE_ROLE_KEY (server-side only via server actions)
|
||||
- Admin users have is_admin=true in profiles table; check via .select('is_admin').eq('id', user.id).single()
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -26,3 +40,223 @@
|
|||
- npm package names can't have capital letters (use lowercase)
|
||||
- .gitignore needs explicit exclusion for .env files, but include .env.example
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-002
|
||||
- What was implemented: TypeScript types for flowchart data structures
|
||||
- Files changed:
|
||||
- src/types/flowchart.ts - new file with all flowchart type definitions
|
||||
- package.json - added typecheck script (tsc --noEmit)
|
||||
- **Learnings for future iterations:**
|
||||
- Position is a helper type for {x, y} coordinates used by nodes
|
||||
- FlowchartNode is a union type of DialogueNode | ChoiceNode | VariableNode
|
||||
- ChoiceOption is a separate type to make options array cleaner
|
||||
- All types use `export type` for TypeScript isolatedModules compatibility
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-003
|
||||
- What was implemented: Supabase schema for users and projects
|
||||
- Files changed:
|
||||
- supabase/migrations/20260121000000_create_profiles_and_projects.sql - new file with all database schema
|
||||
- **Learnings for future iterations:**
|
||||
- Supabase migrations are plain SQL files in supabase/migrations/ directory
|
||||
- Migration filenames use timestamp prefix (YYYYMMDDHHMMSS_description.sql)
|
||||
- RLS policies need separate policies for SELECT, INSERT, UPDATE, DELETE operations
|
||||
- Admin check policy uses EXISTS subquery to check is_admin flag on profiles table
|
||||
- projects table references profiles.id (not auth.users.id directly) for proper FK relationships
|
||||
- flowchart_data column uses JSONB type with default empty structure
|
||||
- Added auto-update trigger for updated_at timestamp on projects table
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-004
|
||||
- What was implemented: Supabase client configuration utilities
|
||||
- Files changed:
|
||||
- src/lib/supabase/client.ts - browser client using createBrowserClient from @supabase/ssr
|
||||
- src/lib/supabase/server.ts - server client for App Router with async cookies() API
|
||||
- src/lib/supabase/middleware.ts - middleware helper with updateSession function
|
||||
- src/lib/.gitkeep - removed (no longer needed)
|
||||
- **Learnings for future iterations:**
|
||||
- @supabase/ssr package provides createBrowserClient and createServerClient functions
|
||||
- Server client requires async cookies() from next/headers in Next.js 16
|
||||
- Middleware client returns both user object and supabaseResponse for route protection
|
||||
- Cookie handling uses getAll/setAll pattern for proper session management
|
||||
- setAll in server.ts wrapped in try/catch to handle Server Component limitations
|
||||
---
|
||||
|
||||
## 2026-01-21 - US-005
|
||||
- What was implemented: Protected routes middleware for authentication
|
||||
- Files changed:
|
||||
- middleware.ts - new file at project root for route protection
|
||||
- **Learnings for future iterations:**
|
||||
- Next.js middleware.ts must be at project root (not in src/)
|
||||
- updateSession helper from lib/supabase/middleware.ts returns { user, supabaseResponse }
|
||||
- Use startsWith() for route matching to handle nested routes (e.g., /editor/*)
|
||||
- Matcher config excludes static files and images to avoid unnecessary middleware calls
|
||||
- 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
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { inviteUser } from './actions'
|
||||
|
||||
export default function InviteForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
setLoading(true)
|
||||
|
||||
const result = await inviteUser(email)
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || 'Failed to send invitation')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
setEmail('')
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{success && (
|
||||
<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">
|
||||
Invitation sent successfully! The user will receive an email with instructions to complete their account setup.
|
||||
</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>
|
||||
<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="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-sm font-medium text-zinc-600 hover:text-zinc-500 dark:text-zinc-400 dark:hover:text-zinc-300"
|
||||
>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="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 ? 'Sending...' : 'Send Invitation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
'use server'
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { createClient as createServerClient } from '@/lib/supabase/server'
|
||||
|
||||
export async function inviteUser(email: string): Promise<{ success: boolean; error?: string }> {
|
||||
// First, verify the current user is an admin
|
||||
const supabase = await createServerClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'You must be logged in to invite users' }
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('is_admin')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (profileError || !profile?.is_admin) {
|
||||
return { success: false, error: 'You do not have permission to invite users' }
|
||||
}
|
||||
|
||||
// Create admin client with service role key for inviting users
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!serviceRoleKey) {
|
||||
return { success: false, error: 'Server configuration error: missing service role key' }
|
||||
}
|
||||
|
||||
const adminClient = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
serviceRoleKey,
|
||||
{
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Invite the user
|
||||
const { error: inviteError } = await adminClient.auth.admin.inviteUserByEmail(email, {
|
||||
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'}/signup`,
|
||||
})
|
||||
|
||||
if (inviteError) {
|
||||
return { success: false, error: inviteError.message }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import InviteForm from './InviteForm'
|
||||
|
||||
export default async function AdminInvitePage() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('is_admin')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (!profile?.is_admin) {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
|
||||
Invite New User
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Send an invitation email to a new user to give them access to WebVNWrite.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InviteForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import Navbar from '@/components/Navbar'
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Fetch user profile to check admin status
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('is_admin')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const isAdmin = profile?.is_admin || false
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<Navbar userEmail={user.email || ''} isAdmin={isAdmin} />
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { createClient } from '@/lib/supabase/server'
|
||||
import NewProjectButton from '@/components/NewProjectButton'
|
||||
import ProjectList from '@/components/ProjectList'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { data: projects, error } = await supabase
|
||||
.from('projects')
|
||||
.select('id, name, updated_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('updated_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-red-700 dark:text-red-400">
|
||||
Failed to load projects. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
|
||||
Your Projects
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Select a project to open the flowchart editor
|
||||
</p>
|
||||
</div>
|
||||
<NewProjectButton />
|
||||
</div>
|
||||
|
||||
<ProjectList initialProjects={projects || []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/reset-password`,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Check your email
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
We've sent a password reset link to <strong>{email}</strong>.
|
||||
Please check your inbox and follow the instructions to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Forgot your password?
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<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 ? 'Sending...' : 'Send reset link'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +1,24 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
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)
|
||||
|
|
@ -46,6 +53,12 @@ export default function LoginPage() {
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter()
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tokenValid, setTokenValid] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Handle recovery token from URL hash
|
||||
// Supabase adds tokens to the URL hash after redirect from reset email
|
||||
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 === 'recovery') {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError('Invalid or expired reset link. Please request a new password reset.')
|
||||
setTokenValid(false)
|
||||
return
|
||||
}
|
||||
|
||||
setTokenValid(true)
|
||||
} else {
|
||||
setError('Invalid or expired reset link. Please request a new password reset.')
|
||||
setTokenValid(false)
|
||||
}
|
||||
} else {
|
||||
// No hash in URL - check if user already has a session from a previous recovery
|
||||
const supabase = createClient()
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
if (session) {
|
||||
setTokenValid(true)
|
||||
} else {
|
||||
setError('Invalid or expired reset link. Please request a new password reset.')
|
||||
setTokenValid(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTokenFromUrl()
|
||||
}, [])
|
||||
|
||||
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()
|
||||
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Sign out after password reset so user can sign in with new password
|
||||
await supabase.auth.signOut()
|
||||
|
||||
// Redirect to login with success message
|
||||
router.push('/login?message=password_reset_success')
|
||||
}
|
||||
|
||||
// Show loading state while checking token
|
||||
if (tokenValid === null) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<p className="text-zinc-600 dark:text-zinc-400">Verifying reset link...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error state if token is invalid
|
||||
if (tokenValid === false) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Reset link expired
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<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"
|
||||
>
|
||||
Request a new reset link
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
Set new password
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Enter your new password below.
|
||||
</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="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
New 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 new 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 ? 'Updating password...' : 'Update password'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
'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 SignupPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
// Pre-fill email if provided in URL (from invite link)
|
||||
const [email, setEmail] = useState(searchParams.get('email') ?? '')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Handle invite/signup token from URL hash
|
||||
// Supabase adds tokens to the URL hash after redirect
|
||||
const handleTokenFromUrl = async () => {
|
||||
const hash = window.location.hash
|
||||
if (hash) {
|
||||
const params = new URLSearchParams(hash.substring(1))
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const type = params.get('type')
|
||||
|
||||
if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError('Invalid or expired invite link. Please request a new invitation.')
|
||||
return
|
||||
}
|
||||
|
||||
// Get the user's email from the session
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user?.email) {
|
||||
setEmail(user.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleTokenFromUrl()
|
||||
}, [searchParams])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Validate passwords match
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
// Check if user already has a session (from invite link)
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
if (session) {
|
||||
// User was invited and has a session - update their password
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
})
|
||||
|
||||
if (updateError) {
|
||||
setError(updateError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Create profile record
|
||||
const { error: profileError } = await supabase.from('profiles').upsert({
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
display_name: session.user.email?.split('@')[0] || 'User',
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
// Regular signup flow (if allowed)
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.user) {
|
||||
// Create profile record
|
||||
const { error: profileError } = await supabase.from('profiles').upsert({
|
||||
id: data.user.id,
|
||||
email: data.user.email,
|
||||
display_name: email.split('@')[0] || 'User',
|
||||
is_admin: false,
|
||||
})
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
WebVNWrite
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Complete your account setup
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function LogoutButton() {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleLogout() {
|
||||
setLoading(true)
|
||||
|
||||
const supabase = createClient()
|
||||
await supabase.auth.signOut()
|
||||
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={loading}
|
||||
className="rounded-md bg-zinc-200 px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-900"
|
||||
>
|
||||
{loading ? 'Signing out...' : 'Sign out'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import Link from 'next/link'
|
||||
import LogoutButton from './LogoutButton'
|
||||
|
||||
interface NavbarProps {
|
||||
userEmail: string
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export default function Navbar({ userEmail, isAdmin }: NavbarProps) {
|
||||
return (
|
||||
<nav className="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-xl font-bold text-zinc-900 dark:text-zinc-50"
|
||||
>
|
||||
WebVNWrite
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/admin/invite"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Invite User
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{userEmail}
|
||||
</span>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function NewProjectButton() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true)
|
||||
setProjectName('')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isLoading) {
|
||||
setIsOpen(false)
|
||||
setProjectName('')
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!projectName.trim()) {
|
||||
setError('Project name is required')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
setError('You must be logged in to create a project')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { data, error: insertError } = await supabase
|
||||
.from('projects')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: projectName.trim(),
|
||||
flowchart_data: { nodes: [], edges: [] },
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (insertError) {
|
||||
setError(insertError.message || 'Failed to create project')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (data) {
|
||||
router.push(`/editor/${data.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-900"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Project
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative 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">
|
||||
Create New Project
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Enter a name for your new visual novel project.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-4">
|
||||
<label
|
||||
htmlFor="projectName"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="projectName"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="My Visual Novel"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50 dark:placeholder-zinc-500"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isLoading}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition-colors 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-300 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 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"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
interface ProjectCardProps {
|
||||
id: string
|
||||
name: string
|
||||
updatedAt: string
|
||||
onDelete: (id: string) => void
|
||||
onRename: (id: string, newName: string) => void
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export default function ProjectCard({
|
||||
id,
|
||||
name,
|
||||
updatedAt,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: ProjectCardProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false)
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [newName, setNewName] = useState(name)
|
||||
const [renameError, setRenameError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const handleCardClick = () => {
|
||||
router.push(`/editor/${id}`)
|
||||
}
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.from('projects').delete().eq('id', id)
|
||||
|
||||
if (error) {
|
||||
setIsDeleting(false)
|
||||
setShowDeleteDialog(false)
|
||||
alert('Failed to delete project: ' + error.message)
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeleting(false)
|
||||
setShowDeleteDialog(false)
|
||||
onDelete(id)
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
if (!isDeleting) {
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenameClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setNewName(name)
|
||||
setRenameError(null)
|
||||
setShowRenameDialog(true)
|
||||
}
|
||||
|
||||
const handleConfirmRename = async () => {
|
||||
if (!newName.trim()) {
|
||||
setRenameError('Project name cannot be empty')
|
||||
return
|
||||
}
|
||||
|
||||
if (newName.trim() === name) {
|
||||
setShowRenameDialog(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsRenaming(true)
|
||||
setRenameError(null)
|
||||
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase
|
||||
.from('projects')
|
||||
.update({ name: newName.trim() })
|
||||
.eq('id', id)
|
||||
|
||||
if (error) {
|
||||
setIsRenaming(false)
|
||||
setRenameError('Failed to rename project: ' + error.message)
|
||||
return
|
||||
}
|
||||
|
||||
setIsRenaming(false)
|
||||
setShowRenameDialog(false)
|
||||
onRename(id, newName.trim())
|
||||
}
|
||||
|
||||
const handleCancelRename = () => {
|
||||
if (!isRenaming) {
|
||||
setShowRenameDialog(false)
|
||||
setRenameError(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="group relative cursor-pointer rounded-lg border border-zinc-200 bg-white p-6 transition-all hover:border-blue-300 hover:shadow-md dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-blue-600"
|
||||
>
|
||||
<div className="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={handleRenameClick}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900/30 dark:hover:text-blue-400"
|
||||
title="Rename project"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400"
|
||||
title="Delete project"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="pr-8 text-lg font-semibold text-zinc-900 group-hover:text-blue-600 dark:text-zinc-50 dark:group-hover:text-blue-400">
|
||||
{name}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Last updated: {formatDate(updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showDeleteDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={handleCancelDelete}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<svg
|
||||
className="h-5 w-5 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Delete Project
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Are you sure you want to delete "{name}"? This action
|
||||
cannot be undone and all flowchart data will be permanently
|
||||
removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelDelete}
|
||||
disabled={isDeleting}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition-colors 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-300 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRenameDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={handleCancelRename}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg
|
||||
className="h-5 w-5 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Rename Project
|
||||
</h3>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="project-name"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isRenaming) {
|
||||
handleConfirmRename()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
disabled={isRenaming}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50 dark:placeholder-zinc-500 dark:focus:border-blue-400 dark:focus:ring-blue-400"
|
||||
placeholder="Enter project name"
|
||||
/>
|
||||
{renameError && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{renameError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelRename}
|
||||
disabled={isRenaming}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition-colors 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-300 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmRename}
|
||||
disabled={isRenaming}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 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"
|
||||
>
|
||||
{isRenaming ? 'Renaming...' : 'Rename'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import ProjectCard from './ProjectCard'
|
||||
import Toast from './Toast'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ProjectListProps {
|
||||
initialProjects: Project[]
|
||||
}
|
||||
|
||||
export default function ProjectList({ initialProjects }: ProjectListProps) {
|
||||
const [projects, setProjects] = useState<Project[]>(initialProjects)
|
||||
const [toast, setToast] = useState<{
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
} | null>(null)
|
||||
|
||||
const handleDelete = useCallback((deletedId: string) => {
|
||||
setProjects((prev) => prev.filter((p) => p.id !== deletedId))
|
||||
setToast({ message: 'Project deleted successfully', type: 'success' })
|
||||
}, [])
|
||||
|
||||
const handleRename = useCallback((id: string, newName: string) => {
|
||||
setProjects((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, name: newName } : p))
|
||||
)
|
||||
setToast({ message: 'Project renamed successfully', type: 'success' })
|
||||
}, [])
|
||||
|
||||
const handleCloseToast = useCallback(() => {
|
||||
setToast(null)
|
||||
}, [])
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-dashed border-zinc-300 bg-zinc-50 p-12 text-center dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-4 text-lg font-medium text-zinc-900 dark:text-zinc-50">
|
||||
No projects yet
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
Get started by creating your first visual novel project.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
id={project.id}
|
||||
name={project.name}
|
||||
updatedAt={project.updated_at}
|
||||
onDelete={handleDelete}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={handleCloseToast}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function Toast({ message, type, onClose }: ToastProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onClose()
|
||||
}, 3000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [onClose])
|
||||
|
||||
const bgColor =
|
||||
type === 'success'
|
||||
? 'bg-green-600 dark:bg-green-700'
|
||||
: 'bg-red-600 dark:bg-red-700'
|
||||
|
||||
const icon =
|
||||
type === 'success' ? (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 animate-in fade-in slide-in-from-bottom-4">
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium text-white shadow-lg ${bgColor}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{message}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-2 rounded p-0.5 hover:bg-white/20"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue