Keep/keep-notes/components/collaborator-dialog.tsx
sepehr 640fcb26f7 fix: improve note interactions and markdown LaTeX support
## Bug Fixes

### Note Card Actions
- Fix broken size change functionality (missing state declaration)
- Implement React 19 useOptimistic for instant UI feedback
- Add startTransition for non-blocking updates
- Ensure smooth animations without page refresh
- All note actions now work: pin, archive, color, size, checklist

### Markdown LaTeX Rendering
- Add remark-math and rehype-katex plugins
- Support inline equations with dollar sign syntax
- Support block equations with double dollar sign syntax
- Import KaTeX CSS for proper styling
- Equations now render correctly instead of showing raw LaTeX

## Technical Details

- Replace undefined currentNote references with optimistic state
- Add optimistic updates before server actions for instant feedback
- Use router.refresh() in transitions for smart cache invalidation
- Install remark-math, rehype-katex, and katex packages

## Testing

- Build passes successfully with no TypeScript errors
- Dev server hot-reloads changes correctly
2026-01-09 22:13:49 +01:00

331 lines
12 KiB
TypeScript

'use client'
import { useState, useTransition, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { X, Loader2, Mail } from "lucide-react"
import { addCollaborator, removeCollaborator, getNoteCollaborators } from "@/app/actions/notes"
import { toast } from "sonner"
interface Collaborator {
id: string
name: string | null
email: string
image: string | null
}
interface CollaboratorDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
noteId: string
noteOwnerId: string
currentUserId: string
onCollaboratorsChange?: (collaboratorIds: string[]) => void
initialCollaborators?: string[]
}
export function CollaboratorDialog({
open,
onOpenChange,
noteId,
noteOwnerId,
currentUserId,
onCollaboratorsChange,
initialCollaborators = []
}: CollaboratorDialogProps) {
const router = useRouter()
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
const [localCollaboratorIds, setLocalCollaboratorIds] = useState<string[]>(initialCollaborators)
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isPending, startTransition] = useTransition()
const [justAddedCollaborator, setJustAddedCollaborator] = useState(false)
const isOwner = currentUserId === noteOwnerId
const isCreationMode = !noteId
const hasLoadedRef = useRef(false)
// Load collaborators when dialog opens (only for existing notes)
const loadCollaborators = async () => {
if (isCreationMode) return
setIsLoading(true)
try {
const result = await getNoteCollaborators(noteId)
setCollaborators(result)
hasLoadedRef.current = true
} catch (error: any) {
toast.error(error.message || 'Error loading collaborators')
} finally {
setIsLoading(false)
}
}
// Load collaborators when dialog opens
useEffect(() => {
if (open && !isCreationMode && !hasLoadedRef.current && !isLoading) {
loadCollaborators()
}
// Reset when dialog closes
if (!open) {
hasLoadedRef.current = false
}
}, [open, isCreationMode])
// Sync initial collaborators when prop changes (creation mode)
useEffect(() => {
if (isCreationMode) {
setLocalCollaboratorIds(initialCollaborators)
}
}, [initialCollaborators, isCreationMode])
// Handle adding a collaborator
const handleAddCollaborator = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
if (isCreationMode) {
// Creation mode: just add email as placeholder, will be resolved on note creation
if (!localCollaboratorIds.includes(email)) {
const newIds = [...localCollaboratorIds, email]
setLocalCollaboratorIds(newIds)
onCollaboratorsChange?.(newIds)
setEmail('')
toast.success(`${email} will be added as collaborator when note is created`)
} else {
toast.warning('This email is already in the list')
}
} else {
// Existing note mode: use server action
setJustAddedCollaborator(true)
startTransition(async () => {
try {
const result = await addCollaborator(noteId, email)
if (result.success) {
setCollaborators([...collaborators, result.user])
setEmail('')
toast.success(`${result.user.name || result.user.email} now has access to this note`)
// Don't refresh here - it would close the dialog!
// The collaborator list is already updated in local state
setJustAddedCollaborator(false)
}
} catch (error: any) {
toast.error(error.message || 'Failed to add collaborator')
setJustAddedCollaborator(false)
}
})
}
}
// Handle removing a collaborator
const handleRemoveCollaborator = async (userId: string) => {
if (isCreationMode) {
// Creation mode: remove from local list
const newIds = localCollaboratorIds.filter(id => id !== userId)
setLocalCollaboratorIds(newIds)
onCollaboratorsChange?.(newIds)
} else {
// Existing note mode: use server action
startTransition(async () => {
try {
await removeCollaborator(noteId, userId)
setCollaborators(collaborators.filter(c => c.id !== userId))
toast.success('Access has been revoked')
// Don't refresh here - it would close the dialog!
// The collaborator list is already updated in local state
} catch (error: any) {
toast.error(error.message || 'Failed to remove collaborator')
}
})
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-md"
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
// Sonner uses data-sonner-toast NOT data-toast
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
// Also prevent if target is the toast container itself
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Share with collaborators</DialogTitle>
<DialogDescription>
{isOwner
? "Add people to collaborate on this note by their email address."
: "You have access to this note. Only the owner can manage collaborators."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{isOwner && (
<form onSubmit={handleAddCollaborator} className="flex gap-2">
<div className="flex-1">
<Label htmlFor="email" className="sr-only">Email address</Label>
<Input
id="email"
type="email"
placeholder="Enter email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isPending}
/>
</div>
<Button type="submit" disabled={isPending || !email.trim()}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Mail className="h-4 w-4 mr-2" />
Invite
</>
)}
</Button>
</form>
)}
<div className="space-y-2">
<Label>People with access</Label>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : isCreationMode ? (
// Creation mode: show emails
localCollaboratorIds.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No collaborators yet. Add someone above!
</p>
) : (
<div className="space-y-2">
{localCollaboratorIds.map((emailOrId, idx) => (
<div
key={idx}
data-testid="collaborator-item"
className="flex items-center justify-between p-2 rounded-lg border bg-muted/50"
>
<div className="flex items-center gap-3 flex-1">
<Avatar className="h-8 w-8">
<AvatarFallback>
{emailOrId.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
Pending Invite
</p>
<p className="text-xs text-muted-foreground truncate">
{emailOrId}
</p>
</div>
<Badge variant="outline" className="ml-2">Pending</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleRemoveCollaborator(emailOrId)}
disabled={isPending}
aria-label="Remove"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)
) : collaborators.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No collaborators yet. {isOwner && "Add someone above!"}
</p>
) : (
<div className="space-y-2">
{collaborators.map((collaborator) => (
<div
key={collaborator.id}
data-testid="collaborator-item"
className="flex items-center justify-between p-2 rounded-lg border bg-muted/50"
>
<div className="flex items-center gap-3 flex-1">
<Avatar className="h-8 w-8">
<AvatarImage src={collaborator.image || undefined} />
<AvatarFallback>
{collaborator.name?.charAt(0).toUpperCase() || collaborator.email.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{collaborator.name || 'Unnamed User'}
</p>
<p className="text-xs text-muted-foreground truncate">
{collaborator.email}
</p>
</div>
{collaborator.id === noteOwnerId && (
<Badge variant="secondary" className="ml-2">Owner</Badge>
)}
</div>
{isOwner && collaborator.id !== noteOwnerId && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleRemoveCollaborator(collaborator.id)}
disabled={isPending}
aria-label="Remove"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}