Keep/keep-notes/components/collaborator-dialog.tsx
sepehr 7fb486c9a4 feat: Complete internationalization and code cleanup
## Translation Files
- Add 11 new language files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl)
- Add 100+ missing translation keys across all 15 languages
- New sections: notebook, pagination, ai.batchOrganization, ai.autoLabels
- Update nav section with workspace, quickAccess, myLibrary keys

## Component Updates
- Update 15+ components to use translation keys instead of hardcoded text
- Components: notebook dialogs, sidebar, header, note-input, ghost-tags, etc.
- Replace 80+ hardcoded English/French strings with t() calls
- Ensure consistent UI across all supported languages

## Code Quality
- Remove 77+ console.log statements from codebase
- Clean up API routes, components, hooks, and services
- Keep only essential error handling (no debugging logs)

## UI/UX Improvements
- Update Keep logo to yellow post-it style (from-yellow-400 to-amber-500)
- Change selection colors to #FEF3C6 (notebooks) and #EFB162 (nav items)
- Make "+" button permanently visible in notebooks section
- Fix grammar and syntax errors in multiple components

## Bug Fixes
- Fix JSON syntax errors in it.json, nl.json, pl.json, zh.json
- Fix syntax errors in notebook-suggestion-toast.tsx
- Fix syntax errors in use-auto-tagging.ts
- Fix syntax errors in paragraph-refactor.service.ts
- Fix duplicate "fusion" section in nl.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

Ou une version plus courte si vous préférez :

feat(i18n): Add 15 languages, remove logs, update UI components

- Create 11 new translation files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl)
- Add 100+ translation keys: notebook, pagination, AI features
- Update 15+ components to use translations (80+ strings)
- Remove 77+ console.log statements from codebase
- Fix JSON syntax errors in 4 translation files
- Fix component syntax errors (toast, hooks, services)
- Update logo to yellow post-it style
- Change selection colors (#FEF3C6, #EFB162)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-11 22:26:13 +01:00

333 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"
import { useLanguage } from "@/lib/i18n"
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 { t } = useLanguage()
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 || t('collaboration.errorLoading'))
} 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(t('collaboration.willBeAdded', { email }))
} else {
toast.warning(t('collaboration.alreadyInList'))
}
} 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(t('collaboration.nowHasAccess', { name: result.user.name || result.user.email }))
// 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 || t('collaboration.failedToAdd'))
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(t('collaboration.accessRevoked'))
// 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 || t('collaboration.failedToRemove'))
}
})
}
}
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>{t('collaboration.shareWithCollaborators')}</DialogTitle>
<DialogDescription>
{isOwner
? t('collaboration.addCollaboratorDescription')
: t('collaboration.viewerDescription')}
</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">{t('collaboration.emailAddress')}</Label>
<Input
id="email"
type="email"
placeholder={t('collaboration.enterEmailAddress')}
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" />
{t('collaboration.invite')}
</>
)}
</Button>
</form>
)}
<div className="space-y-2">
<Label>{t('collaboration.peopleWithAccess')}</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">
{t('collaboration.noCollaborators')}
</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">
{t('collaboration.pendingInvite')}
</p>
<p className="text-xs text-muted-foreground truncate">
{emailOrId}
</p>
</div>
<Badge variant="outline" className="ml-2">{t('collaboration.pending')}</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleRemoveCollaborator(emailOrId)}
disabled={isPending}
aria-label={t('collaboration.remove')}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)
) : collaborators.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
{t('collaboration.noCollaboratorsViewer')} {isOwner && t('collaboration.noCollaborators').split('.')[1]}
</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 || t('collaboration.unnamedUser')}
</p>
<p className="text-xs text-muted-foreground truncate">
{collaborator.email}
</p>
</div>
{collaborator.id === noteOwnerId && (
<Badge variant="secondary" className="ml-2">{t('collaboration.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={t('collaboration.remove')}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('collaboration.done')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}