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
This commit is contained in:
2026-01-09 22:13:49 +01:00
parent 3c4b9d6176
commit 640fcb26f7
218 changed files with 51363 additions and 902 deletions

View File

@@ -0,0 +1,70 @@
'use client'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
interface Collaborator {
id: string
name: string | null
email: string
image: string | null
}
interface CollaboratorAvatarsProps {
collaborators: Collaborator[]
ownerId: string
maxDisplay?: number
}
export function CollaboratorAvatars({ collaborators, ownerId, maxDisplay = 5 }: CollaboratorAvatarsProps) {
if (collaborators.length === 0) return null
const displayCollaborators = collaborators.slice(0, maxDisplay)
const remainingCount = collaborators.length - maxDisplay
return (
<div className="flex items-center gap-1 mt-2">
<TooltipProvider>
{displayCollaborators.map((collaborator) => (
<Tooltip key={collaborator.id}>
<TooltipTrigger asChild>
<div className="relative group">
<Avatar className="h-6 w-6 border-2 border-background">
<AvatarImage src={collaborator.image || undefined} />
<AvatarFallback className="text-xs">
{collaborator.name?.charAt(0).toUpperCase() || collaborator.email.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{collaborator.id === ownerId && (
<div className="absolute -bottom-1 -right-1">
<Badge variant="secondary" className="text-[8px] h-3 px-1 min-w-0">
Owner
</Badge>
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">{collaborator.name || 'Unnamed User'}</p>
<p className="text-xs text-muted-foreground">{collaborator.email}</p>
</TooltipContent>
</Tooltip>
))}
{remainingCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs border-2 border-background">
+{remainingCount}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{remainingCount} more collaborator{remainingCount > 1 ? 's' : ''}</p>
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
)
}

View File

@@ -0,0 +1,330 @@
'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>
)
}

View File

@@ -1,38 +1,48 @@
import React from 'react';
import { TagSuggestion } from '@/lib/ai/types';
import { Loader2, Sparkles, X } from 'lucide-react';
import { Loader2, Sparkles, X, CheckCircle } from 'lucide-react';
import { cn, getHashColor } from '@/lib/utils';
import { LABEL_COLORS } from '@/lib/types';
interface GhostTagsProps {
suggestions: TagSuggestion[];
addedTags: string[]; // Nouveauté : tags déjà présents sur la note
isAnalyzing: boolean;
onSelectTag: (tag: string) => void;
onDismissTag: (tag: string) => void;
className?: string;
}
export function GhostTags({ suggestions, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
console.log('GhostTags Render:', { count: suggestions.length, isAnalyzing, suggestions });
// On n'affiche rien si pas d'analyse et pas de suggestions
if (!isAnalyzing && suggestions.length === 0) return null;
export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
// On filtre pour l'affichage conditionnel global, mais on garde les tags ajoutés pour l'affichage visuel "validé"
const visibleSuggestions = suggestions;
if (!isAnalyzing && visibleSuggestions.length === 0) return null;
return (
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
{/* Indicateur IA discret */}
{isAnalyzing && (
<div className="flex items-center text-purple-500 animate-pulse" title="IA en cours d'analyse...">
<Sparkles className="w-4 h-4" />
</div>
)}
{/* Liste des suggestions */}
{!isAnalyzing && suggestions.map((suggestion) => {
{!isAnalyzing && visibleSuggestions.map((suggestion) => {
const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase());
const colorName = getHashColor(suggestion.tag);
const colorClasses = LABEL_COLORS[colorName];
if (isAdded) {
// Tag déjà ajouté : on l'affiche en mode "confirmé" statique pour ne pas perdre le focus
return (
<div key={suggestion.tag} className={cn("flex items-center px-3 py-1 text-xs font-medium border rounded-full opacity-50 cursor-default", colorClasses.bg, colorClasses.text, colorClasses.border)}>
<CheckCircle className="w-3 h-3 mr-1.5" />
{suggestion.tag}
</div>
)
}
return (
<div
key={suggestion.tag}

View File

@@ -1,14 +1,16 @@
'use client'
import { Suspense } from 'react'
import { Header } from './header'
import { useSearchParams, useRouter } from 'next/navigation'
import { useLabels } from '@/context/LabelContext'
interface HeaderWrapperProps {
onColorFilterChange?: (color: string | null) => void
user?: any
}
export function HeaderWrapper({ onColorFilterChange }: HeaderWrapperProps) {
function HeaderContent({ onColorFilterChange, user }: HeaderWrapperProps) {
const searchParams = useSearchParams()
const router = useRouter()
const { labels } = useLabels()
@@ -47,6 +49,15 @@ export function HeaderWrapper({ onColorFilterChange }: HeaderWrapperProps) {
selectedColor={selectedColor}
onLabelFilterChange={handleLabelFilterChange}
onColorFilterChange={handleColorFilterChange}
user={user}
/>
)
}
export function HeaderWrapper(props: HeaderWrapperProps) {
return (
<Suspense fallback={<div className="h-16 border-b bg-background/95" />}>
<HeaderContent {...props} />
</Suspense>
)
}

View File

@@ -17,26 +17,31 @@ import {
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Trash2, Archive } from 'lucide-react'
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Trash2, Archive, Coffee } from 'lucide-react'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { LabelManagementDialog } from './label-management-dialog'
import { LabelFilter } from './label-filter'
import { NotificationPanel } from './notification-panel'
import { UserNav } from './user-nav'
import { updateTheme } from '@/app/actions/profile'
interface HeaderProps {
selectedLabels?: string[]
selectedColor?: string | null
onLabelFilterChange?: (labels: string[]) => void
onColorFilterChange?: (color: string | null) => void
user?: any
}
export function Header({
selectedLabels = [],
selectedColor = null,
onLabelFilterChange,
onColorFilterChange
onColorFilterChange,
user
}: HeaderProps = {}) {
const [searchQuery, setSearchQuery] = useState('')
const [theme, setTheme] = useState<'light' | 'dark'>('light')
@@ -55,11 +60,12 @@ export function Header({
}, [currentSearch])
useEffect(() => {
const savedTheme = localStorage.getItem('theme') || 'light'
applyTheme(savedTheme)
}, [])
const savedTheme = user?.theme || localStorage.getItem('theme') || 'light'
// Don't persist on initial load to avoid unnecessary DB calls
applyTheme(savedTheme, false)
}, [user])
const applyTheme = (newTheme: string) => {
const applyTheme = async (newTheme: string, persist = true) => {
setTheme(newTheme as any)
localStorage.setItem('theme', newTheme)
@@ -75,6 +81,10 @@ export function Header({
document.documentElement.classList.add('dark')
}
}
if (persist && user) {
await updateTheme(newTheme)
}
}
const handleSearch = (query: string) => {
@@ -130,21 +140,59 @@ export function Header({
router.push(`/?${params.toString()}`)
}
const NavItem = ({ href, icon: Icon, label, active }: any) => (
<Link
href={href}
onClick={() => setIsSidebarOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
>
<Icon className={cn("h-5 w-5", active && "fill-current")} />
{label}
</Link>
)
const toggleLabelFilter = (labelName: string) => {
const newLabels = currentLabels.includes(labelName)
? currentLabels.filter(l => l !== labelName)
: [...currentLabels, labelName]
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) {
params.set('labels', newLabels.join(','))
} else {
params.delete('labels')
}
router.push(`/?${params.toString()}`)
}
const NavItem = ({ href, icon: Icon, label, active, onClick }: any) => {
const content = (
<>
<Icon className={cn("h-5 w-5", active && "fill-current")} />
{label}
</>
)
if (onClick) {
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2 text-left",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
>
{content}
</button>
)
}
return (
<Link
href={href}
onClick={() => setIsSidebarOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
>
{content}
</Link>
)
}
const hasActiveFilters = currentLabels.length > 0 || !!currentSearch || !!currentColor
@@ -188,26 +236,32 @@ export function Header({
{labels.map(label => (
<NavItem
key={label.id}
href={`/?labels=${encodeURIComponent(label.name)}`}
icon={Tag}
label={label.name}
active={currentLabels.includes(label.name)}
onClick={() => toggleLabelFilter(label.name)}
/>
))}
<div className="my-2 border-t border-gray-200 dark:border-zinc-800" />
<NavItem
href="/archive"
icon={Archive}
label="Archive"
active={pathname === '/archive'}
<NavItem
href="/archive"
icon={Archive}
label="Archive"
active={pathname === '/archive'}
/>
<NavItem
href="/trash"
icon={Trash2}
label="Trash"
active={pathname === '/trash'}
<NavItem
href="/trash"
icon={Trash2}
label="Trash"
active={pathname === '/trash'}
/>
<NavItem
href="/support"
icon={Coffee}
label="Support ☕"
active={pathname === '/support'}
/>
</div>
</SheetContent>
@@ -241,9 +295,7 @@ export function Header({
<div className="absolute right-0 top-0 h-full flex items-center pr-2">
<LabelFilter
selectedLabels={currentLabels}
selectedColor={currentColor || null}
onFilterChange={handleFilterChange}
onColorChange={handleColorChange}
/>
</div>
</div>
@@ -262,6 +314,9 @@ export function Header({
<DropdownMenuItem onClick={() => applyTheme('sepia')}>Sepia</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NotificationPanel />
<UserNav user={user} />
</div>
</div>

View File

@@ -5,27 +5,23 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Filter } from 'lucide-react'
import { LABEL_COLORS } from '@/lib/types'
import { Filter, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { LabelBadge } from './label-badge'
interface LabelFilterProps {
selectedLabels: string[]
selectedColor?: string | null
onFilterChange: (labels: string[]) => void
onColorChange?: (color: string | null) => void
}
export function LabelFilter({ selectedLabels, selectedColor, onFilterChange, onColorChange }: LabelFilterProps) {
const { labels, loading, getLabelColor } = useLabels()
export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps) {
const { labels, loading } = useLabels()
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
useEffect(() => {
@@ -43,15 +39,6 @@ export function LabelFilter({ selectedLabels, selectedColor, onFilterChange, onC
const handleClearAll = () => {
onFilterChange([])
onColorChange?.(null)
}
const handleColorFilter = (color: string) => {
if (selectedColor === color) {
onColorChange?.(null)
} else {
onColorChange?.(color)
}
}
if (loading || allLabelNames.length === 0) return null
@@ -86,56 +73,35 @@ export function LabelFilter({ selectedLabels, selectedColor, onFilterChange, onC
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Color Filter */}
<div className="p-2">
<p className="text-xs font-medium mb-2 text-gray-600 dark:text-gray-400">Filter by Color</p>
<div className="grid grid-cols-3 gap-2">
{Object.entries(LABEL_COLORS).map(([colorName, colorClasses]) => {
const isSelected = selectedColor === colorName
const labelCount = labels.filter((l: any) => l.color === colorName).length
return (
<button
key={colorName}
onClick={() => handleColorFilter(colorName)}
className={cn(
'flex items-center gap-2 p-2 rounded-lg border-2 transition-all hover:scale-105',
isSelected ? 'ring-2 ring-blue-500' : 'border-gray-300 dark:border-gray-600'
)}
>
<div
className={cn(
'w-6 h-6 rounded-full border-2',
colorClasses.bg,
isSelected ? 'border-blue-500 dark:border-blue-400' : colorClasses.border
)}
/>
<span className="text-xs ml-2">{labelCount}</span>
</button>
)
})}
</div>
</div>
<DropdownMenuSeparator />
{/* Label Filters */}
{!loading && allLabelNames.map((labelName: string) => {
const isSelected = selectedLabels.includes(labelName)
const isColorFiltered = selectedColor && selectedColor !== 'gray'
<div className="max-h-64 overflow-y-auto px-1 pb-1">
{!loading && allLabelNames.map((labelName: string) => {
const isSelected = selectedLabels.includes(labelName)
return (
<DropdownMenuCheckboxItem
key={labelName}
checked={isSelected && !isColorFiltered}
onCheckedChange={() => handleToggleLabel(labelName)}
>
<LabelBadge
label={labelName}
isDisabled={!!isColorFiltered}
/>
</DropdownMenuCheckboxItem>
)
})}
return (
<div
key={labelName}
onClick={(e) => {
e.preventDefault()
handleToggleLabel(labelName)
}}
className={cn(
"flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm hover:bg-accent hover:text-accent-foreground"
)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border border-primary",
isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible"
)}>
<Check className="h-3 w-3" />
</div>
<LabelBadge
label={labelName}
/>
</div>
)
})}
</div>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -60,7 +60,32 @@ export function LabelManagementDialog() {
<Settings className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogContent
className="max-w-md"
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
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;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Edit Labels</DialogTitle>
<DialogDescription>

View File

@@ -102,7 +102,32 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
Labels
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogContent
className="max-w-md"
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
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;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Manage Labels</DialogTitle>
<DialogDescription>

View File

@@ -94,7 +94,7 @@ export function LabelSelector({
e.preventDefault()
handleToggleLabel(label.name)
}}
className="flex items-center gap-2 p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-zinc-800 cursor-pointer text-sm"
className="flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm hover:bg-accent hover:text-accent-foreground"
>
<div className={cn(
"h-4 w-4 border rounded flex items-center justify-center transition-colors",
@@ -113,7 +113,7 @@ export function LabelSelector({
e.preventDefault()
handleCreateLabel()
}}
className="flex items-center gap-2 p-2 rounded-sm hover:bg-gray-100 dark:hover:bg-zinc-800 cursor-pointer text-sm border-t mt-1"
className="flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm border-t mt-1 font-medium hover:bg-accent hover:text-accent-foreground"
>
<Plus className="h-4 w-4" />
<span>Create "{search}"</span>

View File

@@ -16,7 +16,7 @@ function LoginButton() {
);
}
export function LoginForm() {
export function LoginForm({ allowRegister = true }: { allowRegister?: boolean }) {
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
return (
@@ -64,6 +64,14 @@ export function LoginForm() {
</div>
</div>
</div>
<div className="flex items-center justify-end mt-2">
<Link
href="/forgot-password"
className="text-xs text-gray-500 hover:text-gray-900 underline"
>
Forgot password?
</Link>
</div>
<LoginButton />
<div
className="flex h-8 items-end space-x-1"
@@ -74,12 +82,14 @@ export function LoginForm() {
<p className="text-sm text-red-500">{errorMessage}</p>
)}
</div>
<div className="mt-4 text-center text-sm">
Don't have an account?{' '}
<Link href="/register" className="underline">
Register
</Link>
</div>
{allowRegister && (
<div className="mt-4 text-center text-sm">
Don't have an account?{' '}
<Link href="/register" className="underline">
Register
</Link>
</div>
)}
</div>
</form>
);

View File

@@ -2,6 +2,9 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
interface MarkdownContentProps {
content: string
@@ -10,8 +13,16 @@ interface MarkdownContentProps {
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
return (
<div className={`prose prose-sm dark:prose-invert max-w-none ${className}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
<div className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
a: ({ node, ...props }) => (
<a {...props} className="text-blue-500 hover:underline" target="_blank" rel="noopener noreferrer" />
)
}}
>
{content}
</ReactMarkdown>
</div>

View File

@@ -1,8 +1,8 @@
'use client';
'use client'
import { useState, useEffect, useRef, useCallback, memo } from 'react';
import { Note } from '@/lib/types';
import { NoteCard } from './note-card';
import { useState, useEffect, useRef, useCallback } from 'react';
import { NoteEditor } from './note-editor';
import { updateFullOrder } from '@/app/actions/notes';
import { useResizeObserver } from '@/hooks/use-resize-observer';
@@ -13,18 +13,32 @@ interface MasonryGridProps {
interface MasonryItemProps {
note: Note;
onEdit: (note: Note) => void;
onEdit: (note: Note, readOnly?: boolean) => void;
onResize: () => void;
}
function MasonryItem({ note, onEdit, onResize }: MasonryItemProps) {
function getSizeClasses(size: string = 'small') {
switch (size) {
case 'medium':
return 'w-full sm:w-full lg:w-2/3 xl:w-2/4 2xl:w-2/5';
case 'large':
return 'w-full';
case 'small':
default:
return 'w-full sm:w-1/2 lg:w-1/3 xl:w-1/4 2xl:w-1/5';
}
}
const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize }: MasonryItemProps) {
const resizeRef = useResizeObserver(() => {
onResize();
});
const sizeClasses = getSizeClasses(note.size);
return (
<div
className="masonry-item absolute w-full sm:w-1/2 lg:w-1/3 xl:w-1/4 2xl:w-1/5 p-2"
className={`masonry-item absolute p-2 ${sizeClasses}`}
data-id={note.id}
ref={resizeRef as any}
>
@@ -33,20 +47,28 @@ function MasonryItem({ note, onEdit, onResize }: MasonryItemProps) {
</div>
</div>
);
}
}, (prev, next) => {
// Custom comparison to avoid re-render on function prop changes if note data is same
return prev.note === next.note;
});
export function MasonryGrid({ notes }: MasonryGridProps) {
const [editingNote, setEditingNote] = useState<Note | null>(null);
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
const pinnedGridRef = useRef<HTMLDivElement>(null);
const othersGridRef = useRef<HTMLDivElement>(null);
const pinnedMuuri = useRef<any>(null);
const othersMuuri = useRef<any>(null);
const isDraggingRef = useRef(false);
const pinnedNotes = notes.filter(n => n.isPinned).sort((a, b) => a.order - b.order);
const othersNotes = notes.filter(n => !n.isPinned).sort((a, b) => a.order - b.order);
const handleDragEnd = async (grid: any) => {
if (!grid) return;
// Prevent layout refresh during server update
isDraggingRef.current = true;
const items = grid.getItems();
const ids = items
.map((item: any) => item.getElement()?.getAttribute('data-id'))
@@ -56,6 +78,11 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
await updateFullOrder(ids);
} catch (error) {
console.error('Failed to persist order:', error);
} finally {
// Reset after animation/server roundtrip
setTimeout(() => {
isDraggingRef.current = false;
}, 1000);
}
};
@@ -82,8 +109,13 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
if (!isMounted) return;
// Detect if we are on a touch device (mobile behavior)
const isMobile = window.matchMedia('(pointer: coarse)').matches;
const layoutOptions = {
dragEnabled: true,
// On mobile, restrict drag to handle to allow scrolling. On desktop, allow drag from anywhere.
dragHandle: isMobile ? '.drag-handle' : undefined,
dragContainer: document.body,
dragStartPredicate: {
distance: 10,
@@ -125,10 +157,12 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
pinnedMuuri.current = null;
othersMuuri.current = null;
};
}, [pinnedNotes.length, othersNotes.length]);
}, [pinnedNotes.length > 0, othersNotes.length > 0]);
// Synchronize items when notes change (e.g. searching, adding)
useEffect(() => {
if (isDraggingRef.current) return;
if (pinnedMuuri.current) {
pinnedMuuri.current.refreshItems().layout();
}
@@ -144,10 +178,10 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">Pinned</h2>
<div ref={pinnedGridRef} className="relative min-h-[100px]">
{pinnedNotes.map(note => (
<MasonryItem
key={note.id}
note={note}
onEdit={setEditingNote}
<MasonryItem
key={note.id}
note={note}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onResize={refreshLayout}
/>
))}
@@ -162,10 +196,10 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
)}
<div ref={othersGridRef} className="relative min-h-[100px]">
{othersNotes.map(note => (
<MasonryItem
key={note.id}
note={note}
onEdit={setEditingNote}
<MasonryItem
key={note.id}
note={note}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onResize={refreshLayout}
/>
))}
@@ -174,7 +208,11 @@ export function MasonryGrid({ notes }: MasonryGridProps) {
)}
{editingNote && (
<NoteEditor note={editingNote} onClose={() => setEditingNote(null)} />
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
<style jsx global>{`

View File

@@ -13,6 +13,8 @@ import {
Palette,
Pin,
Trash2,
Users,
Maximize2,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { NOTE_COLORS } from "@/lib/types"
@@ -21,10 +23,13 @@ interface NoteActionsProps {
isPinned: boolean
isArchived: boolean
currentColor: string
currentSize?: 'small' | 'medium' | 'large'
onTogglePin: () => void
onToggleArchive: () => void
onColorChange: (color: string) => void
onSizeChange?: (size: 'small' | 'medium' | 'large') => void
onDelete: () => void
onShareCollaborators?: () => void
className?: string
}
@@ -32,10 +37,13 @@ export function NoteActions({
isPinned,
isArchived,
currentColor,
currentSize = 'small',
onTogglePin,
onToggleArchive,
onColorChange,
onSizeChange,
onDelete,
onShareCollaborators,
className
}: NoteActionsProps) {
return (
@@ -43,17 +51,6 @@ export function NoteActions({
className={cn("flex items-center justify-end gap-1", className)}
onClick={(e) => e.stopPropagation()}
>
{/* Pin Button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={onTogglePin}
title={isPinned ? 'Unpin' : 'Pin'}
>
<Pin className={cn('h-4 w-4', isPinned && 'fill-current')} />
</Button>
{/* Color Palette */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -82,7 +79,7 @@ export function NoteActions({
{/* More Options */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" aria-label="More options">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -100,6 +97,46 @@ export function NoteActions({
</>
)}
</DropdownMenuItem>
{/* Size Selector */}
{onSizeChange && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
Size
</div>
{(['small', 'medium', 'large'] as const).map((size) => (
<DropdownMenuItem
key={size}
onClick={() => onSizeChange(size)}
className={cn(
"capitalize",
currentSize === size && "bg-accent"
)}
>
<Maximize2 className="h-4 w-4 mr-2" />
{size}
</DropdownMenuItem>
))}
</>
)}
{/* Collaborators */}
{onShareCollaborators && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onShareCollaborators()
}}
>
<Users className="h-4 w-4 mr-2" />
Share with collaborators
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onDelete} className="text-red-600 dark:text-red-400">
<Trash2 className="h-4 w-4 mr-2" />

View File

@@ -2,9 +2,12 @@
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Card } from '@/components/ui/card'
import { Pin, Bell } from 'lucide-react'
import { useState } from 'react'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
import { Button } from '@/components/ui/button'
import { Pin, Bell, GripVertical, X } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, getNoteAllUsers, leaveSharedNote } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
@@ -13,20 +16,60 @@ import { LabelBadge } from './label-badge'
import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist'
import { NoteActions } from './note-actions'
import { CollaboratorDialog } from './collaborator-dialog'
import { CollaboratorAvatars } from './collaborator-avatars'
import { useLabels } from '@/context/LabelContext'
interface NoteCardProps {
note: Note
onEdit?: (note: Note) => void
onEdit?: (note: Note, readOnly?: boolean) => void
isDragging?: boolean
isDragOver?: boolean
}
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
const router = useRouter()
const { refreshLabels } = useLabels()
const { data: session } = useSession()
const [isPending, startTransition] = useTransition()
const [isDeleting, setIsDeleting] = useState(false)
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const [collaborators, setCollaborators] = useState<any[]>([])
const [owner, setOwner] = useState<any>(null)
const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
// Optimistic UI state for instant feedback
const [optimisticNote, addOptimisticNote] = useOptimistic(
note,
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
)
const currentUserId = session?.user?.id
const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
const isOwner = currentUserId && note.userId && currentUserId === note.userId
// Load collaborators when note changes
useEffect(() => {
const loadCollaborators = async () => {
if (note.userId) {
try {
const users = await getNoteAllUsers(note.id)
setCollaborators(users)
// Owner is always first in the list
if (users.length > 0) {
setOwner(users[0])
}
} catch (error) {
console.error('Failed to load collaborators:', error)
setCollaborators([])
}
}
}
loadCollaborators()
}, [note.id, note.userId])
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this note?')) {
setIsDeleting(true)
@@ -42,15 +85,35 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
}
const handleTogglePin = async () => {
await togglePin(note.id, !note.isPinned)
startTransition(async () => {
addOptimisticNote({ isPinned: !note.isPinned })
await togglePin(note.id, !note.isPinned)
router.refresh()
})
}
const handleToggleArchive = async () => {
await toggleArchive(note.id, !note.isArchived)
startTransition(async () => {
addOptimisticNote({ isArchived: !note.isArchived })
await toggleArchive(note.id, !note.isArchived)
router.refresh()
})
}
const handleColorChange = async (color: string) => {
await updateColor(note.id, color)
startTransition(async () => {
addOptimisticNote({ color })
await updateColor(note.id, color)
router.refresh()
})
}
const handleSizeChange = async (size: 'small' | 'medium' | 'large') => {
startTransition(async () => {
addOptimisticNote({ size })
await updateNote(note.id, { size })
router.refresh()
})
}
const handleCheckItem = async (checkItemId: string) => {
@@ -58,7 +121,22 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
const updatedItems = note.checkItems.map(item =>
item.id === checkItemId ? { ...item, checked: !item.checked } : item
)
await updateNote(note.id, { checkItems: updatedItems })
startTransition(async () => {
addOptimisticNote({ checkItems: updatedItems })
await updateNote(note.id, { checkItems: updatedItems })
router.refresh()
})
}
}
const handleLeaveShare = async () => {
if (confirm('Are you sure you want to leave this shared note?')) {
try {
await leaveSharedNote(note.id)
setIsDeleting(true) // Hide the note from view
} catch (error) {
console.error('Failed to leave share:', error)
}
}
}
@@ -66,6 +144,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
return (
<Card
data-testid="note-card"
className={cn(
'note-card-main group relative p-4 transition-all duration-200 border cursor-move',
'hover:shadow-md',
@@ -78,40 +157,79 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
onClick={(e) => {
// Only trigger edit if not clicking on buttons
const target = e.target as HTMLElement
if (!target.closest('button') && !target.closest('[role="checkbox"]')) {
onEdit?.(note)
if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.drag-handle')) {
// For shared notes, pass readOnly flag
onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean)
}
}}
>
{/* Pin Icon */}
{note.isPinned && (
<Pin className="absolute top-3 right-3 h-4 w-4 text-gray-600 dark:text-gray-400" fill="currentColor" />
)}
{/* Drag Handle - Visible only on mobile/touch devices */}
<div className="absolute top-2 left-2 z-20 md:hidden cursor-grab active:cursor-grabbing drag-handle touch-none">
<GripVertical className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
{/* Reminder Icon */}
{/* Pin Button - Visible on hover or if pinned, always accessible */}
<Button
variant="ghost"
size="sm"
className={cn(
"absolute top-2 right-2 z-20 h-8 w-8 p-0 rounded-full transition-opacity",
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100",
"md:flex", // On desktop follow hover logic
"flex" // Ensure it's a flex container for the icon
)}
onClick={(e) => {
e.stopPropagation();
handleTogglePin();
}}
>
<Pin
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-blue-600" : "text-gray-400")}
/>
</Button>
{/* Reminder Icon - Move slightly if pin button is there */}
{note.reminder && new Date(note.reminder) > new Date() && (
<Bell
className={cn(
"absolute h-4 w-4 text-blue-600 dark:text-blue-400",
note.isPinned ? "top-3 right-9" : "top-3 right-3"
)}
className="absolute top-3 right-10 h-4 w-4 text-blue-600 dark:text-blue-400"
/>
)}
{/* Title */}
{note.title && (
<h3 className="text-base font-medium mb-2 pr-6 text-gray-900 dark:text-gray-100">
{note.title}
{optimisticNote.title && (
<h3 className="text-base font-medium mb-2 pr-10 text-gray-900 dark:text-gray-100">
{optimisticNote.title}
</h3>
)}
{/* Shared badge */}
{isSharedNote && owner && (
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
Shared by {owner.name || owner.email}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
onClick={(e) => {
e.stopPropagation()
handleLeaveShare()
}}
>
<X className="h-3 w-3 mr-1" />
Leave
</Button>
</div>
)}
{/* Images Component */}
<NoteImages images={note.images || []} title={note.title} />
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
{/* Link Previews */}
{note.links && note.links.length > 0 && (
{optimisticNote.links && optimisticNote.links.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{note.links.map((link, idx) => (
{optimisticNote.links.map((link, idx) => (
<a
key={idx}
href={link.url}
@@ -136,48 +254,68 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
)}
{/* Content */}
{note.type === 'text' ? (
note.isMarkdown ? (
<div className="text-sm line-clamp-10">
<MarkdownContent content={note.content} />
</div>
) : (
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap line-clamp-10">
{note.content}
</p>
)
{optimisticNote.type === 'text' ? (
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-10">
<MarkdownContent content={optimisticNote.content} />
</div>
) : (
<NoteChecklist
items={note.checkItems || []}
onToggleItem={handleCheckItem}
<NoteChecklist
items={optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
)}
{/* Labels */}
{note.labels && note.labels.length > 0 && (
{optimisticNote.labels && optimisticNote.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{note.labels.map((label) => (
{optimisticNote.labels.map((label) => (
<LabelBadge key={label} label={label} />
))}
</div>
)}
{/* Collaborators */}
{optimisticNote.userId && collaborators.length > 0 && (
<CollaboratorAvatars
collaborators={collaborators}
ownerId={optimisticNote.userId}
/>
)}
{/* Creation Date */}
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: fr })}
</div>
{/* Action Bar Component */}
<NoteActions
isPinned={note.isPinned}
isArchived={note.isArchived}
currentColor={note.color}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onColorChange={handleColorChange}
onDelete={handleDelete}
className="absolute bottom-0 left-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity"
/>
{/* Action Bar Component - Only for owner */}
{isOwner && (
<NoteActions
isPinned={optimisticNote.isPinned}
isArchived={optimisticNote.isArchived}
currentColor={optimisticNote.color}
currentSize={optimisticNote.size}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onColorChange={handleColorChange}
onSizeChange={handleSizeChange}
onDelete={handleDelete}
onShareCollaborators={() => setShowCollaboratorDialog(true)}
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}
{/* Collaborator Dialog */}
{currentUserId && note.userId && (
<div onClick={(e) => e.stopPropagation()}>
<CollaboratorDialog
open={showCollaboratorDialog}
onOpenChange={setShowCollaboratorDialog}
noteId={note.id}
noteOwnerId={note.userId}
currentUserId={currentUserId}
/>
</div>
)}
</Card>
)
}

View File

@@ -18,11 +18,11 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles } from 'lucide-react'
import { updateNote } from '@/app/actions/notes'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy } from 'lucide-react'
import { updateNote, createNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/toast'
import { toast } from 'sonner'
import { MarkdownContent } from './markdown-content'
import { LabelManager } from './label-manager'
import { LabelBadge } from './label-badge'
@@ -31,14 +31,16 @@ import { EditorImages } from './editor-images'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from './ghost-tags'
import { useLabels } from '@/context/LabelContext'
import { NoteSize } from '@/lib/types'
import { Badge } from '@/components/ui/badge'
interface NoteEditorProps {
note: Note
readOnly?: boolean
onClose: () => void
}
export function NoteEditor({ note, onClose }: NoteEditorProps) {
const { addToast } = useToast()
export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) {
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content)
@@ -48,9 +50,10 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const [links, setLinks] = useState<LinkMetadata[]>(note.links || [])
const [newLabel, setNewLabel] = useState('')
const [color, setColor] = useState(note.color)
const [size, setSize] = useState<NoteSize>(note.size || 'small')
const [isSaving, setIsSaving] = useState(false)
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.isMarkdown || false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Auto-tagging hook
@@ -88,7 +91,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
console.error('Erreur création label auto:', err)
}
}
addToast(`Tag "${tag}" ajouté`, 'success')
toast.success(`Tag "${tag}" ajouté`)
}
}
@@ -96,11 +99,11 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setDismissedTags(prev => [...prev, tag])
}
// Filtrer les suggestions pour ne pas afficher celles rejetées ou déjà ajoutées (insensible à la casse)
// Filtrer les suggestions pour ne pas afficher celles rejetées par l'utilisateur
// (On garde celles déjà ajoutées pour les afficher en mode "validé")
const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false
return !labels.some(l => l.toLowerCase() === s.tag.toLowerCase()) &&
!dismissedTags.includes(s.tag)
return !dismissedTags.includes(s.tag)
})
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -123,7 +126,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setImages(prev => [...prev, data.url])
} catch (error) {
console.error('Upload error:', error)
addToast(`Failed to upload ${file.name}`, 'error')
toast.error(`Failed to upload ${file.name}`)
}
}
}
@@ -141,14 +144,14 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const metadata = await fetchLinkMetadata(linkUrl)
if (metadata) {
setLinks(prev => [...prev, metadata])
addToast('Link added', 'success')
toast.success('Link added')
} else {
addToast('Could not fetch link metadata', 'warning')
toast.warning('Could not fetch link metadata')
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
}
} catch (error) {
console.error('Failed to add link:', error)
addToast('Failed to add link', 'error')
toast.error('Failed to add link')
} finally {
setLinkUrl('')
}
@@ -160,16 +163,16 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
const handleReminderSave = (date: Date) => {
if (date < new Date()) {
addToast('Reminder must be in the future', 'error')
toast.error('Reminder must be in the future')
return
}
setCurrentReminder(date)
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
toast.success(`Reminder set for ${date.toLocaleString()}`)
}
const handleRemoveReminder = () => {
setCurrentReminder(null)
addToast('Reminder removed', 'success')
toast.success('Reminder removed')
}
const handleSave = async () => {
@@ -185,6 +188,7 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
color,
reminder: currentReminder,
isMarkdown,
size,
})
// Rafraîchir les labels globaux pour refléter les suppressions éventuelles (orphans)
@@ -227,16 +231,57 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
setLabels(labels.filter(l => l !== label))
}
const handleMakeCopy = async () => {
try {
const newNote = await createNote({
title: `${title || 'Untitled'} (Copy)`,
content: content,
color: color,
type: note.type,
checkItems: checkItems,
labels: labels,
images: images,
links: links,
isMarkdown: isMarkdown,
size: size,
})
toast.success('Note copied successfully!')
onClose()
// Force refresh to show the new note
window.location.reload()
} catch (error) {
console.error('Failed to copy note:', error)
toast.error('Failed to copy note')
}
}
return (
<Dialog open onOpenChange={onClose}>
<Dialog open={true} onOpenChange={onClose}>
<DialogContent
className={cn(
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-y-auto',
colorClasses.bg
)}
onInteractOutside={(event) => {
// Prevent ALL outside interactions from closing dialog
// This prevents closing when clicking outside (including on toasts)
event.preventDefault()
}}
onPointerDownOutside={(event) => {
// Prevent ALL pointer down outside from closing dialog
event.preventDefault()
}}
>
<DialogHeader>
<DialogTitle className="sr-only">Edit Note</DialogTitle>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{readOnly ? 'View Note' : 'Edit Note'}</h2>
{readOnly && (
<Badge variant="secondary" className="bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
Read Only
</Badge>
)}
</div>
</DialogHeader>
<div className="space-y-4">
@@ -246,7 +291,11 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-8"
disabled={readOnly}
className={cn(
"text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-8",
readOnly && "cursor-default"
)}
/>
{filteredSuggestions.length > 0 && (
<div className="absolute right-0 top-1/2 -translate-y-1/2" title="Suggestions IA disponibles">
@@ -327,8 +376,8 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
</div>
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
content={content || '*No content*'}
<MarkdownContent
content={content || '*No content*'}
className="min-h-[200px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
/>
) : (
@@ -336,13 +385,18 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
placeholder={isMarkdown ? "Take a note... (Markdown supported)" : "Take a note..."}
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none"
disabled={readOnly}
className={cn(
"min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none",
readOnly && "cursor-default"
)}
/>
)}
{/* AI Auto-tagging Suggestions */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={labels}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
@@ -401,76 +455,130 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
{/* Toolbar */}
<div className="flex items-center justify-between pt-4 border-t">
<div className="flex items-center gap-2">
{/* Reminder Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowReminderDialog(true)}
title="Set reminder"
className={currentReminder ? "text-blue-600" : ""}
>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image Button */}
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
title="Add image"
>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowLinkDialog(true)}
title="Add Link"
>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" title="Change color">
<Palette className="h-4 w-4" />
{!readOnly && (
<>
{/* Reminder Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowReminderDialog(true)}
title="Set reminder"
className={currentReminder ? "text-blue-600" : ""}
>
<Bell className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button
key={colorName}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => setColor(colorName)}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Label Manager */}
<LabelManager
existingLabels={labels}
onUpdate={setLabels}
/>
{/* Add Image Button */}
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
title="Add image"
>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link Button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowLinkDialog(true)}
title="Add Link"
>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Size Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" title="Change size">
<Maximize2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="flex flex-col gap-1 p-1">
{['small', 'medium', 'large'].map((s) => (
<Button
key={s}
variant="ghost"
size="sm"
onClick={() => setSize(s as NoteSize)}
className={cn(
"justify-start capitalize",
size === s && "bg-accent"
)}
>
{s}
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" title="Change color">
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button
key={colorName}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => setColor(colorName)}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Label Manager */}
<LabelManager
existingLabels={labels}
onUpdate={setLabels}
/>
</>
)}
{readOnly && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="text-xs">This note is shared with you in read-only mode</span>
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
{readOnly ? (
<>
<Button
variant="default"
onClick={handleMakeCopy}
className="flex items-center gap-2"
>
<Copy className="h-4 w-4" />
Make a copy
</Button>
<Button variant="ghost" onClick={onClose}>
Close
</Button>
</>
) : (
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</>
)}
</div>
</div>
</div>

View File

@@ -36,14 +36,16 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/toast'
import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { MarkdownContent } from './markdown-content'
import { LabelSelector } from './label-selector'
import { LabelBadge } from './label-badge'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from './ghost-tags'
import { CollaboratorDialog } from './collaborator-dialog'
import { useLabels } from '@/context/LabelContext'
import { useSession } from 'next-auth/react'
interface HistoryState {
title: string
@@ -58,23 +60,36 @@ interface NoteState {
}
export function NoteInput() {
const { addToast } = useToast()
const { labels: globalLabels, addLabel } = useLabels()
const { data: session } = useSession()
const [isExpanded, setIsExpanded] = useState(false)
const [type, setType] = useState<'text' | 'checklist'>('text')
const [isSubmitting, setIsSubmitting] = useState(false)
const [color, setColor] = useState<NoteColor>('default')
const [isArchived, setIsArchived] = useState(false)
const [selectedLabels, setSelectedLabels] = useState<string[]>([])
const [collaborators, setCollaborators] = useState<string[]>([])
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Simple state without complex undo/redo - like Google Keep
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([])
const [links, setLinks] = useState<LinkMetadata[]>([])
const [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
// Combine text content and link metadata for AI analysis
const fullContentForAI = [
content,
...links.map(l => `${l.title || ''} ${l.description || ''}`)
].join(' ').trim();
// Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({
content: type === 'text' ? content : '',
content: type === 'text' ? fullContentForAI : '',
enabled: type === 'text' && isExpanded
})
@@ -96,7 +111,7 @@ export function NoteInput() {
}
}
addToast(`Tag "${tag}" ajouté`, 'success')
toast.success(`Tag "${tag}" ajouté`)
}
}
@@ -109,13 +124,6 @@ export function NoteInput() {
return !selectedLabels.some(l => l.toLowerCase() === s.tag.toLowerCase()) &&
!dismissedTags.includes(s.tag)
})
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([])
const [links, setLinks] = useState<LinkMetadata[]>([])
const [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
// Undo/Redo history (title and content only)
const [history, setHistory] = useState<HistoryState[]>([{ title: '', content: '' }])
@@ -208,12 +216,12 @@ export function NoteInput() {
for (const file of Array.from(files)) {
// Validation
if (!validTypes.includes(file.type)) {
addToast(`Invalid file type: ${file.name}. Only JPEG, PNG, GIF, and WebP allowed.`, 'error')
toast.error(`Invalid file type: ${file.name}. Only JPEG, PNG, GIF, and WebP allowed.`)
continue
}
if (file.size > maxSize) {
addToast(`File too large: ${file.name}. Maximum size is 5MB.`, 'error')
toast.error(`File too large: ${file.name}. Maximum size is 5MB.`)
continue
}
@@ -233,7 +241,7 @@ export function NoteInput() {
setImages(prev => [...prev, data.url])
} catch (error) {
console.error('Upload error:', error)
addToast(`Failed to upload ${file.name}`, 'error')
toast.error(`Failed to upload ${file.name}`)
}
}
@@ -251,15 +259,15 @@ export function NoteInput() {
const metadata = await fetchLinkMetadata(linkUrl)
if (metadata) {
setLinks(prev => [...prev, metadata])
addToast('Link added', 'success')
toast.success('Link added')
} else {
addToast('Could not fetch link metadata', 'warning')
toast.warning('Could not fetch link metadata')
// Fallback: just add the url as title
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
}
} catch (error) {
console.error('Failed to add link:', error)
addToast('Failed to add link', 'error')
toast.error('Failed to add link')
} finally {
setLinkUrl('')
}
@@ -278,7 +286,7 @@ export function NoteInput() {
const handleReminderSave = () => {
if (!reminderDate || !reminderTime) {
addToast('Please enter date and time', 'warning')
toast.warning('Please enter date and time')
return
}
@@ -286,34 +294,34 @@ export function NoteInput() {
const date = new Date(dateTimeString)
if (isNaN(date.getTime())) {
addToast('Invalid date or time', 'error')
toast.error('Invalid date or time')
return
}
if (date < new Date()) {
addToast('Reminder must be in the future', 'error')
toast.error('Reminder must be in the future')
return
}
setCurrentReminder(date)
addToast(`Reminder set for ${date.toLocaleString()}`, 'success')
toast.success(`Reminder set for ${date.toLocaleString()}`)
setShowReminderDialog(false)
setReminderDate('')
setReminderTime('')
}
const handleSubmit = async () => {
// Validation
if (type === 'text' && !content.trim()) {
addToast('Please enter some content', 'warning')
// Validation: Allow submit if content OR images OR links exist
const hasContent = content.trim().length > 0;
const hasMedia = images.length > 0 || links.length > 0;
const hasCheckItems = checkItems.some(i => i.text.trim().length > 0);
if (type === 'text' && !hasContent && !hasMedia) {
toast.warning('Please enter some content or add a link/image')
return
}
if (type === 'checklist' && checkItems.length === 0) {
addToast('Please add at least one item', 'warning')
return
}
if (type === 'checklist' && checkItems.every(item => !item.text.trim())) {
addToast('Checklist items cannot be empty', 'warning')
if (type === 'checklist' && !hasCheckItems && !hasMedia) {
toast.warning('Please add at least one item or media')
return
}
@@ -331,6 +339,7 @@ export function NoteInput() {
reminder: currentReminder,
isMarkdown,
labels: selectedLabels.length > 0 ? selectedLabels : undefined,
sharedWith: collaborators.length > 0 ? collaborators : undefined,
})
// Reset form
@@ -349,11 +358,12 @@ export function NoteInput() {
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
setCollaborators([])
addToast('Note created successfully', 'success')
toast.success('Note created successfully')
} catch (error) {
console.error('Failed to create note:', error)
addToast('Failed to create note', 'error')
toast.error('Failed to create note')
} finally {
setIsSubmitting(false)
}
@@ -391,6 +401,7 @@ export function NoteInput() {
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
setCollaborators([])
}
if (!isExpanded) {
@@ -462,11 +473,32 @@ export function NoteInput() {
{/* Link Previews */}
{links.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{/* ... */}
{links.map((link, idx) => (
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-white/50 dark:bg-black/20 flex">
{link.imageUrl && (
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-500 truncate hover:underline block mt-1">
{new URL(link.url).hostname}
</a>
</div>
<Button
variant="ghost"
size="sm"
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* Selected Labels Display (Moved here to be visible for both text and checklist) */}
{/* Selected Labels Display */}
{selectedLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{selectedLabels.map(label => (
@@ -523,6 +555,7 @@ export function NoteInput() {
{/* AI Auto-tagging Suggestions */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={selectedLabels}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={handleDismissGhostTag}
@@ -620,11 +653,17 @@ export function NoteInput() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Collaborator">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Add collaborators"
onClick={() => setShowCollaboratorDialog(true)}
>
<UserPlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Collaborator</TooltipContent>
<TooltipContent>Add collaborators</TooltipContent>
</Tooltip>
<Tooltip>
@@ -766,7 +805,31 @@ export function NoteInput() {
</Card>
<Dialog open={showReminderDialog} onOpenChange={setShowReminderDialog}>
<DialogContent>
<DialogContent
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
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;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Set Reminder</DialogTitle>
</DialogHeader>
@@ -808,7 +871,31 @@ export function NoteInput() {
</Dialog>
<Dialog open={showLinkDialog} onOpenChange={setShowLinkDialog}>
<DialogContent>
<DialogContent
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
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;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Add Link</DialogTitle>
</DialogHeader>
@@ -836,6 +923,16 @@ export function NoteInput() {
</DialogFooter>
</DialogContent>
</Dialog>
<CollaboratorDialog
open={showCollaboratorDialog}
onOpenChange={setShowCollaboratorDialog}
noteId=""
noteOwnerId={session?.user?.id || ""}
currentUserId={session?.user?.id || ""}
onCollaboratorsChange={setCollaborators}
initialCollaborators={collaborators}
/>
</>
)
}
}

View File

@@ -0,0 +1,234 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, User } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { getPendingShareRequests, respondToShareRequest, removeSharedNoteFromView } from '@/app/actions/notes'
import { toast } from 'sonner'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { cn } from '@/lib/utils'
interface ShareRequest {
id: string
status: string
permission: string
createdAt: Date
note: {
id: string
title: string | null
content: string
color: string
createdAt: Date
}
sharer: {
id: string
name: string | null
email: string
image: string | null
}
}
export function NotificationPanel() {
const router = useRouter()
const { triggerRefresh } = useNoteRefresh()
const [requests, setRequests] = useState<ShareRequest[]>([])
const [isLoading, setIsLoading] = useState(false)
const [pendingCount, setPendingCount] = useState(0)
const loadRequests = async () => {
setIsLoading(true)
try {
const data = await getPendingShareRequests()
setRequests(data)
setPendingCount(data.length)
} catch (error: any) {
console.error('Failed to load share requests:', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadRequests()
const interval = setInterval(loadRequests, 10000)
return () => clearInterval(interval)
}, [])
const handleAccept = async (shareId: string) => {
console.log('[NOTIFICATION] Accepting share:', shareId)
try {
await respondToShareRequest(shareId, 'accept')
console.log('[NOTIFICATION] Share accepted, calling router.refresh()')
router.refresh()
console.log('[NOTIFICATION] Calling triggerRefresh()')
triggerRefresh()
setRequests(prev => prev.filter(r => r.id !== shareId))
setPendingCount(prev => prev - 1)
toast.success('Note shared successfully!', {
description: 'The note now appears in your list',
duration: 3000,
})
console.log('[NOTIFICATION] Done! Note should appear now')
} catch (error: any) {
console.error('[NOTIFICATION] Error:', error)
toast.error(error.message || 'Error')
}
}
const handleDecline = async (shareId: string) => {
console.log('[NOTIFICATION] Declining share:', shareId)
try {
await respondToShareRequest(shareId, 'decline')
router.refresh()
triggerRefresh()
setRequests(prev => prev.filter(r => r.id !== shareId))
setPendingCount(prev => prev - 1)
toast.info('Share declined')
} catch (error: any) {
console.error('[NOTIFICATION] Error:', error)
toast.error(error.message || 'Error')
}
}
const handleRemove = async (shareId: string) => {
try {
await removeSharedNoteFromView(shareId)
router.refresh()
triggerRefresh()
setRequests(prev => prev.filter(r => r.id !== shareId))
toast.info('Request hidden')
} catch (error: any) {
toast.error(error.message || 'Error')
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-9 w-9 p-0 hover:bg-accent/50 transition-all duration-200"
>
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
{pendingCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs animate-pulse shadow-lg"
>
{pendingCount > 9 ? '9+' : pendingCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<div className="px-4 py-3 border-b bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="font-semibold text-sm">Pending Shares</span>
</div>
{pendingCount > 0 && (
<Badge className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
{pendingCount}
</Badge>
)}
</div>
</div>
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
<div className="animate-spin h-6 w-6 border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-2" />
Loading...
</div>
) : requests.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
<Bell className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">No pending share requests</p>
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{requests.map((request) => (
<div
key={request.id}
className="p-4 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div className="flex items-start gap-3 mb-3">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-xs shadow-md">
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">
{request.sharer.name || request.sharer.email}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
shared &quot;{request.note.title || 'Untitled'}&quot;
</p>
</div>
<Badge
variant="secondary"
className="text-xs capitalize bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 border-0"
>
{request.permission}
</Badge>
</div>
<div className="flex gap-2 mt-3">
<button
onClick={() => handleAccept(request.id)}
className={cn(
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
"bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700",
"text-white shadow-md hover:shadow-lg",
"transition-all duration-200",
"flex items-center justify-center gap-1.5",
"active:scale-95"
)}
>
<Check className="h-3.5 w-3.5" />
YES
</button>
<button
onClick={() => handleDecline(request.id)}
className={cn(
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
"bg-white dark:bg-gray-800",
"border-2 border-gray-200 dark:border-gray-700",
"text-gray-700 dark:text-gray-300",
"hover:bg-gray-50 dark:hover:bg-gray-700",
"hover:border-gray-300 dark:hover:border-gray-600",
"transition-all duration-200",
"flex items-center justify-center gap-1.5",
"active:scale-95"
)}
>
<X className="h-3.5 w-3.5" />
NO
</button>
</div>
<div className="flex items-center gap-1.5 mt-3 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{new Date(request.createdAt).toLocaleDateString()}</span>
<button
onClick={() => handleRemove(request.id)}
className="ml-auto text-muted-foreground hover:text-foreground transition-colors duration-150"
>
Hide
</button>
</div>
</div>
))}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -49,7 +49,31 @@ export function ReminderDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
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;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>Set Reminder</DialogTitle>
</DialogHeader>

View File

@@ -0,0 +1,7 @@
'use client'
import { SessionProvider } from 'next-auth/react'
export function SessionProviderWrapper({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}

View File

@@ -1,21 +1,25 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { StickyNote, Bell, Archive, Trash2, Tag, ChevronDown, ChevronUp, Settings } from 'lucide-react'
import { StickyNote, Bell, Archive, Trash2, Tag, ChevronDown, ChevronUp, Settings, User, Shield, Coffee } from 'lucide-react'
import { useLabels } from '@/context/LabelContext'
import { LabelManagementDialog } from './label-management-dialog'
import { useSession } from 'next-auth/react'
import { LABEL_COLORS } from '@/lib/types'
export function Sidebar({ className }: { className?: string }) {
export function Sidebar({ className, user }: { className?: string, user?: any }) {
const pathname = usePathname()
const searchParams = useSearchParams()
const { labels, getLabelColor } = useLabels()
const [isLabelsExpanded, setIsLabelsExpanded] = useState(false)
const { data: session } = useSession()
const currentUser = user || session?.user
const currentLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
const currentSearch = searchParams.get('search')
@@ -28,9 +32,9 @@ export function Sidebar({ className }: { className?: string }) {
href={href}
onClick={onClick}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors",
active
? "bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
>
@@ -40,31 +44,31 @@ export function Sidebar({ className }: { className?: string }) {
)
return (
<aside className={cn("w-[280px] flex-col gap-1 py-2 overflow-y-auto hidden md:flex", className)}>
<NavItem
href="/"
icon={StickyNote}
label="Notes"
active={pathname === '/' && currentLabels.length === 0 && !currentSearch}
<aside className={cn("w-[280px] flex-col gap-1 py-2 overflow-y-auto overflow-x-hidden hidden md:flex", className)}>
<NavItem
href="/"
icon={StickyNote}
label="Notes"
active={pathname === '/' && currentLabels.length === 0 && !currentSearch}
/>
<NavItem
href="/reminders"
icon={Bell}
label="Reminders"
active={pathname === '/reminders'}
<NavItem
href="/reminders"
icon={Bell}
label="Reminders"
active={pathname === '/reminders'}
/>
<div className="my-2 px-4 flex items-center justify-between group">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Labels</span>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<LabelManagementDialog />
</div>
</div>
{displayedLabels.map(label => {
const colorName = getLabelColor(label.name)
const colorClass = LABEL_COLORS[colorName]?.icon
return (
<NavItem
key={label.id}
@@ -80,7 +84,7 @@ export function Sidebar({ className }: { className?: string }) {
{hasMoreLabels && (
<button
onClick={() => setIsLabelsExpanded(!isLabelsExpanded)}
className="flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2 w-full hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
className="flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors w-full hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
>
{isLabelsExpanded ? (
<ChevronUp className="h-5 w-5" />
@@ -105,11 +109,37 @@ export function Sidebar({ className }: { className?: string }) {
label="Trash"
active={pathname === '/trash'}
/>
<div className="my-2 border-t border-gray-200 dark:border-zinc-800" />
<NavItem
href="/settings"
icon={Settings}
label="Settings"
active={pathname === '/settings'}
href="/settings/profile"
icon={User}
label="Profile"
active={pathname === '/settings/profile'}
/>
{(currentUser as any)?.role === 'ADMIN' && (
<NavItem
href="/admin"
icon={Shield}
label="Admin"
active={pathname === '/admin'}
/>
)}
<NavItem
href="/support"
icon={Coffee}
label="Support Memento ☕"
active={pathname === '/support'}
/>
<NavItem
href="/settings"
icon={Settings}
label="Diagnostics"
active={pathname === '/settings'}
/>
</aside>
)

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,16 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
export function Label({ className, ...props }: LabelProps) {
return (
<label
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
)
}

View File

@@ -1,85 +1,27 @@
'use client'
import * as React from "react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner'
export interface ToastProps {
id: string
message: string
type?: 'success' | 'error' | 'info' | 'warning'
duration?: number
onClose: (id: string) => void
}
export function Toast({ id, message, type = 'info', duration = 3000, onClose }: ToastProps) {
React.useEffect(() => {
const timer = setTimeout(() => {
onClose(id)
}, duration)
return () => clearTimeout(timer)
}, [id, duration, onClose])
const bgColors = {
success: 'bg-green-600',
error: 'bg-red-600',
info: 'bg-blue-600',
warning: 'bg-yellow-600'
}
// Re-export toast functions from Sonner
export const toast = sonnerToast
// Toaster component with custom styles
export function Toaster() {
return (
<div
className={cn(
"flex items-center gap-2 rounded-lg px-4 py-3 text-sm text-white shadow-lg animate-in slide-in-from-top-5",
bgColors[type]
)}
>
<span className="flex-1">{message}</span>
<button
onClick={() => onClose(id)}
className="rounded-full p-1 hover:bg-white/20 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
<SonnerToaster
position="top-right"
expand={false}
richColors
closeButton
duration={3000}
toastOptions={{
classNames: {
toast: 'toast pointer-events-auto',
description: 'toast-description',
actionButton: 'toast-action-button',
closeButton: 'toast-close-button',
},
}}
/>
)
}
export interface ToastContextType {
addToast: (message: string, type?: 'success' | 'error' | 'info' | 'warning') => void
}
const ToastContext = React.createContext<ToastContextType | null>(null)
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<Array<Omit<ToastProps, 'onClose'>>>([])
const addToast = React.useCallback((message: string, type: 'success' | 'error' | 'info' | 'warning' = 'info') => {
const id = Math.random().toString(36).substring(7)
setToasts(prev => [...prev, { id, message, type }])
}, [])
const removeToast = React.useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id))
}, [])
return (
<ToastContext.Provider value={{ addToast }}>
{children}
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-80">
{toasts.map(toast => (
<Toast key={toast.id} {...toast} onClose={removeToast} />
))}
</div>
</ToastContext.Provider>
)
}
export function useToast() {
const context = React.useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}

View File

@@ -0,0 +1,75 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useSession, signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { LogOut, Settings, User, Shield } from 'lucide-react'
export function UserNav({ user }: { user?: any }) {
const { data: session } = useSession()
const router = useRouter()
const currentUser = user || session?.user
if (!currentUser) return null
const userRole = (currentUser as any).role || currentUser.role
const userInitials = currentUser.name
? currentUser.name.split(' ').map((n: string) => n[0]).join('').toUpperCase().substring(0, 2)
: 'U'
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={currentUser.image || ''} alt={currentUser.name || ''} />
<AvatarFallback>{userInitials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{currentUser.name}</p>
<p className="text-xs leading-none text-muted-foreground">
{currentUser.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push('/settings/profile')}>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</DropdownMenuItem>
{userRole === 'ADMIN' && (
<DropdownMenuItem onClick={() => router.push('/admin')}>
<Shield className="mr-2 h-4 w-4" />
<span>Admin Dashboard</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => router.push('/settings')}>
<Settings className="mr-2 h-4 w-4" />
<span>Diagnostics</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut({ callbackUrl: '/login' })}>
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}