fix: i18n system overhaul and sidebar UI bugs
- Fix LanguageProvider: add RTL support (ar/fa), translation caching, prevent blank flash during load, browser language detection - Fix detect-user-language: extend whitelist from 5 to all 15 languages - Remove hardcoded initialLanguage="fr" from auth layout - Complete fr.json translation (all sections translated from English) - Add missing admin.ai keys to all 13 non-English locales - Translate ai.autoLabels, ai.batchOrganization, memoryEcho sections for all locales - Remove duplicate top-level autoLabels/batchOrganization from en.json - Fix notebook creation: replace window.location.reload() with createNotebookOptimistic + router.refresh() - Fix notebook name truncation in sidebar with min-w-0 - Remove redundant router.refresh() after note creation in page.tsx Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { memo, useState, useEffect } from 'react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import { getConnectionsCount } from '@/lib/connections-cache'
|
||||
|
||||
interface ConnectionsBadgeProps {
|
||||
noteId: string
|
||||
@@ -11,54 +12,44 @@ interface ConnectionsBadgeProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ConnectionData {
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
createdAt: Date
|
||||
similarity: number
|
||||
daysApart: number
|
||||
}
|
||||
|
||||
interface ConnectionsResponse {
|
||||
connections: ConnectionData[]
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) {
|
||||
export const ConnectionsBadge = memo(function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) {
|
||||
const { t } = useLanguage()
|
||||
const [connectionCount, setConnectionCount] = useState<number>(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [fetchAttempted, setFetchAttempted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchAttempted) return
|
||||
setFetchAttempted(true)
|
||||
|
||||
let isMounted = true
|
||||
|
||||
const fetchConnections = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=1`)
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch connections')
|
||||
const count = await getConnectionsCount(noteId)
|
||||
if (isMounted) {
|
||||
setConnectionCount(count)
|
||||
}
|
||||
|
||||
const data: ConnectionsResponse = await res.json()
|
||||
setConnectionCount(data.pagination.total || 0)
|
||||
} catch (error) {
|
||||
console.error('[ConnectionsBadge] Failed to fetch connections:', error)
|
||||
setConnectionCount(0)
|
||||
if (isMounted) {
|
||||
setConnectionCount(0)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isMounted) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchConnections()
|
||||
}, [noteId])
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [noteId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Don't render if no connections or still loading
|
||||
if (connectionCount === 0 || isLoading) {
|
||||
@@ -69,19 +60,11 @@ export function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadg
|
||||
const badgeText = t('memoryEcho.connectionsBadge', { count: connectionCount, plural })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded',
|
||||
'bg-amber-100 dark:bg-amber-900/30',
|
||||
'text-amber-700 dark:text-amber-400',
|
||||
'text-[10px] font-medium',
|
||||
'border border-amber-200 dark:border-amber-800',
|
||||
'cursor-pointer',
|
||||
'transition-all duration-150 ease-out',
|
||||
'hover:bg-amber-200 dark:hover:bg-amber-800/50',
|
||||
isHovered && 'scale-105',
|
||||
className
|
||||
)}
|
||||
<div className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 transition-all duration-150 ease-out',
|
||||
isHovered && 'scale-105',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
@@ -91,4 +74,4 @@ export function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadg
|
||||
{badgeText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
|
||||
const NOTEBOOK_ICONS = [
|
||||
{ icon: Folder, name: 'folder' },
|
||||
@@ -48,6 +49,7 @@ interface CreateNotebookDialogProps {
|
||||
export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialogProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const { createNotebookOptimistic } = useNotebooks()
|
||||
const [name, setName] = useState('')
|
||||
const [selectedIcon, setSelectedIcon] = useState('folder')
|
||||
const [selectedColor, setSelectedColor] = useState('#3B82F6')
|
||||
@@ -61,24 +63,14 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/notebooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
icon: selectedIcon,
|
||||
color: selectedColor,
|
||||
}),
|
||||
await createNotebookOptimistic({
|
||||
name: name.trim(),
|
||||
icon: selectedIcon,
|
||||
color: selectedColor,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Close dialog and reload
|
||||
onOpenChange?.(false)
|
||||
window.location.reload()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
console.error('Failed to create notebook:', error)
|
||||
}
|
||||
// Close dialog — no full page reload needed
|
||||
onOpenChange?.(false)
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to create notebook:', error)
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
@@ -11,7 +12,7 @@ interface MarkdownContentProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
return (
|
||||
<div className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
|
||||
<ReactMarkdown
|
||||
@@ -20,11 +21,11 @@ export function MarkdownContent({ content, className = '' }: MarkdownContentProp
|
||||
components={{
|
||||
a: ({ node, ...props }) => (
|
||||
<a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" />
|
||||
)
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ interface MasonryItemProps {
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
const MasonryItem = function MasonryItem({ note, onEdit, onResize, onNoteSizeChange, onDragStart, onDragEnd, isDragging }: MasonryItemProps) {
|
||||
const MasonryItem = memo(function MasonryItem({ note, onEdit, onResize, onNoteSizeChange, onDragStart, onDragEnd, isDragging }: MasonryItemProps) {
|
||||
const resizeRef = useResizeObserver(onResize);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -55,7 +55,7 @@ const MasonryItem = function MasonryItem({ note, onEdit, onResize, onNoteSizeCha
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
})
|
||||
|
||||
export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
@@ -87,6 +87,21 @@ export function NoteActions({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* Pin/Unpin Option */}
|
||||
<DropdownMenuItem onClick={onTogglePin}>
|
||||
{isPinned ? (
|
||||
<>
|
||||
<Pin className="h-4 w-4 mr-2" />
|
||||
{t('notes.unpin')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin className="h-4 w-4 mr-2" />
|
||||
{t('notes.pin')}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onToggleArchive}>
|
||||
{isArchived ? (
|
||||
<>
|
||||
|
||||
@@ -10,14 +10,28 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Tag } from 'lucide-react'
|
||||
import { useState, useEffect, useTransition, useOptimistic } from 'react'
|
||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2 } from 'lucide-react'
|
||||
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow, Locale } from 'date-fns'
|
||||
import * as dateFnsLocales from 'date-fns/locale'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { es } from 'date-fns/locale/es'
|
||||
import { de } from 'date-fns/locale/de'
|
||||
import { faIR } from 'date-fns/locale/fa-IR'
|
||||
import { it } from 'date-fns/locale/it'
|
||||
import { pt } from 'date-fns/locale/pt'
|
||||
import { ru } from 'date-fns/locale/ru'
|
||||
import { zhCN } from 'date-fns/locale/zh-CN'
|
||||
import { ja } from 'date-fns/locale/ja'
|
||||
import { ko } from 'date-fns/locale/ko'
|
||||
import { ar } from 'date-fns/locale/ar'
|
||||
import { hi } from 'date-fns/locale/hi'
|
||||
import { nl } from 'date-fns/locale/nl'
|
||||
import { pl } from 'date-fns/locale/pl'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { NoteImages } from './note-images'
|
||||
@@ -36,25 +50,25 @@ import { toast } from 'sonner'
|
||||
|
||||
// Mapping of supported languages to date-fns locales
|
||||
const localeMap: Record<string, Locale> = {
|
||||
en: dateFnsLocales.enUS,
|
||||
fr: dateFnsLocales.fr,
|
||||
es: dateFnsLocales.es,
|
||||
de: dateFnsLocales.de,
|
||||
fa: dateFnsLocales.faIR,
|
||||
it: dateFnsLocales.it,
|
||||
pt: dateFnsLocales.pt,
|
||||
ru: dateFnsLocales.ru,
|
||||
zh: dateFnsLocales.zhCN,
|
||||
ja: dateFnsLocales.ja,
|
||||
ko: dateFnsLocales.ko,
|
||||
ar: dateFnsLocales.ar,
|
||||
hi: dateFnsLocales.hi,
|
||||
nl: dateFnsLocales.nl,
|
||||
pl: dateFnsLocales.pl,
|
||||
en: enUS,
|
||||
fr: fr,
|
||||
es: es,
|
||||
de: de,
|
||||
fa: faIR,
|
||||
it: it,
|
||||
pt: pt,
|
||||
ru: ru,
|
||||
zh: zhCN,
|
||||
ja: ja,
|
||||
ko: ko,
|
||||
ar: ar,
|
||||
hi: hi,
|
||||
nl: nl,
|
||||
pl: pl,
|
||||
}
|
||||
|
||||
function getDateLocale(language: string): Locale {
|
||||
return localeMap[language] || dateFnsLocales.enUS
|
||||
return localeMap[language] || enUS
|
||||
}
|
||||
|
||||
// Map icon names to lucide-react components
|
||||
@@ -118,7 +132,7 @@ function getAvatarColor(name: string): string {
|
||||
return colors[hash % colors.length]
|
||||
}
|
||||
|
||||
export function NoteCard({
|
||||
export const NoteCard = memo(function NoteCard({
|
||||
note,
|
||||
onEdit,
|
||||
onDragStart,
|
||||
@@ -133,7 +147,7 @@ export function NoteCard({
|
||||
const { data: session } = useSession()
|
||||
const { t, language } = useLanguage()
|
||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [, startTransition] = useTransition()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
||||
const [collaborators, setCollaborators] = useState<any[]>([])
|
||||
@@ -172,23 +186,33 @@ export function NoteCard({
|
||||
|
||||
// Load collaborators when note changes
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
const loadCollaborators = async () => {
|
||||
if (note.userId) {
|
||||
if (note.userId && isMounted) {
|
||||
try {
|
||||
const users = await getNoteAllUsers(note.id)
|
||||
setCollaborators(users)
|
||||
// Owner is always first in the list
|
||||
if (users.length > 0) {
|
||||
setOwner(users[0])
|
||||
if (isMounted) {
|
||||
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([])
|
||||
if (isMounted) {
|
||||
setCollaborators([])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCollaborators()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [note.id, note.userId])
|
||||
|
||||
const handleDelete = async () => {
|
||||
@@ -525,47 +549,12 @@ export function NoteCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Labels - ONLY show if note belongs to a notebook (labels are contextual per PRD) */}
|
||||
{/* Labels - using shared LabelBadge component */}
|
||||
{optimisticNote.notebookId && optimisticNote.labels && optimisticNote.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{optimisticNote.labels.map((label) => {
|
||||
// Map label names to Keep style colors
|
||||
const getLabelColor = (labelName: string) => {
|
||||
if (labelName.includes('hôtels') || labelName.includes('réservations')) {
|
||||
return 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300'
|
||||
} else if (labelName.includes('vols') || labelName.includes('flight')) {
|
||||
return 'bg-sky-50 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300'
|
||||
} else if (labelName.includes('restos') || labelName.includes('restaurant')) {
|
||||
return 'bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300'
|
||||
} else {
|
||||
return 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
}
|
||||
}
|
||||
|
||||
// Map label names to Keep style icons
|
||||
const getLabelIcon = (labelName: string) => {
|
||||
if (labelName.includes('hôtels')) return 'label'
|
||||
else if (labelName.includes('vols')) return 'flight'
|
||||
else if (labelName.includes('restos')) return 'restaurant'
|
||||
else return 'label'
|
||||
}
|
||||
|
||||
const icon = getLabelIcon(label)
|
||||
const colorClass = getLabelColor(label)
|
||||
|
||||
return (
|
||||
<span
|
||||
key={label}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold",
|
||||
colorClass
|
||||
)}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{optimisticNote.labels.map((label) => (
|
||||
<LabelBadge key={label} label={label} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -655,4 +644,4 @@ export function NoteCard({
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -234,7 +234,7 @@ export function NotebooksList() {
|
||||
)}
|
||||
>
|
||||
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm font-medium tracking-wide truncate flex-1 text-left">{notebook.name}</span>
|
||||
<span className="text-sm font-medium tracking-wide truncate min-w-0 text-left">{notebook.name}</span>
|
||||
{notebook.notesCount > 0 && (
|
||||
<span className="text-xs text-gray-400 ml-2 flex-shrink-0">({notebook.notesCount})</span>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition, useOptimistic } from 'react'
|
||||
import { Note } from '@/lib/types'
|
||||
import { Clock } from 'lucide-react'
|
||||
import { Clock, Pin, FolderOpen, Trash2, Folder, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Button } from './ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'
|
||||
import { togglePin, deleteNote, dismissFromRecent } from '@/app/actions/notes'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { toast } from 'sonner'
|
||||
import { StickyNote } from 'lucide-react'
|
||||
|
||||
interface RecentNotesSectionProps {
|
||||
recentNotes: Note[]
|
||||
@@ -53,16 +62,89 @@ function CompactCard({
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const timeAgo = getCompactTime(note.contentUpdatedAt || note.updatedAt, t)
|
||||
const router = useRouter()
|
||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
||||
const [, startTransition] = useTransition()
|
||||
|
||||
// Optimistic UI state
|
||||
const [optimisticNote, addOptimisticNote] = useOptimistic(
|
||||
note,
|
||||
(state: Note, newProps: Partial<Note>) => ({ ...state, ...newProps })
|
||||
)
|
||||
|
||||
const timeAgo = getCompactTime(optimisticNote.contentUpdatedAt || optimisticNote.updatedAt, t)
|
||||
const isFirstNote = index === 0
|
||||
|
||||
const handleTogglePin = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
startTransition(async () => {
|
||||
const newPinnedState = !optimisticNote.isPinned
|
||||
addOptimisticNote({ isPinned: newPinnedState })
|
||||
await togglePin(note.id, newPinnedState)
|
||||
|
||||
// Trigger global refresh to update lists
|
||||
triggerRefresh()
|
||||
router.refresh()
|
||||
|
||||
if (newPinnedState) {
|
||||
toast.success(t('notes.pinned') || 'Note pinned')
|
||||
} else {
|
||||
toast.info(t('notes.unpinned') || 'Note unpinned')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleMoveToNotebook = async (notebookId: string | null) => {
|
||||
await moveNoteToNotebookOptimistic(note.id, notebookId)
|
||||
setShowNotebookMenu(false)
|
||||
triggerRefresh()
|
||||
}
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(t('notes.confirmDelete'))) {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
triggerRefresh()
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
// Optimistic removal
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await dismissFromRecent(note.id)
|
||||
// Don't refresh list to prevent immediate replacement
|
||||
// triggerRefresh()
|
||||
// router.refresh()
|
||||
toast.success(t('notes.dismissed') || 'Note dismissed from recent')
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss note:', error)
|
||||
setIsDeleting(false)
|
||||
toast.error(t('common.error') || 'Failed to dismiss')
|
||||
}
|
||||
}
|
||||
|
||||
if (isDeleting) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onEdit?.(note)}
|
||||
<div
|
||||
className={cn(
|
||||
"group relative text-left p-4 bg-card border rounded-xl shadow-sm hover:shadow-md transition-all duration-200 min-h-[44px]",
|
||||
isFirstNote && "ring-2 ring-primary/20"
|
||||
"group relative flex flex-col p-4 bg-card border rounded-xl shadow-sm hover:shadow-md transition-all duration-200 min-h-[44px]",
|
||||
isFirstNote && "ring-2 ring-primary/20",
|
||||
"cursor-pointer"
|
||||
)}
|
||||
onClick={() => onEdit?.(note)}
|
||||
>
|
||||
<div className={cn(
|
||||
"absolute left-0 top-0 bottom-0 w-1 rounded-l-xl",
|
||||
@@ -73,14 +155,83 @@ function CompactCard({
|
||||
: "bg-muted dark:bg-muted/60"
|
||||
)} />
|
||||
|
||||
{/* Action Buttons Overlay - Ensure visible on hover and touch devices often handle this with tap */}
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
||||
{/* Pin Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
|
||||
onClick={handleTogglePin}
|
||||
title={optimisticNote.isPinned ? t('notes.unpin') : t('notes.pin')}
|
||||
>
|
||||
<Pin className={cn("h-3.5 w-3.5", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")} />
|
||||
</Button>
|
||||
|
||||
{/* Move to Notebook */}
|
||||
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={t('notebookSuggestion.moveToNotebook')}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
{t('notebookSuggestion.moveToNotebook')}
|
||||
</div>
|
||||
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
|
||||
<StickyNote className="h-4 w-4 mr-2" />
|
||||
{t('notebookSuggestion.generalNotes')}
|
||||
</DropdownMenuItem>
|
||||
{notebooks.map((notebook: any) => (
|
||||
<DropdownMenuItem
|
||||
key={notebook.id}
|
||||
onClick={() => handleMoveToNotebook(notebook.id)}
|
||||
>
|
||||
<Folder className="h-4 w-4 mr-2" />
|
||||
{notebook.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Dismiss Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:text-destructive bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
|
||||
onClick={handleDismiss}
|
||||
title={t('common.close') || 'Dismiss'}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Delete Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:text-destructive bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
|
||||
onClick={handleDelete}
|
||||
title={t('notes.delete')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="pl-2">
|
||||
<h3 className="text-sm font-semibold text-foreground line-clamp-1 mb-2">
|
||||
{note.title || t('notes.untitled')}
|
||||
{optimisticNote.title || t('notes.untitled')}
|
||||
</h3>
|
||||
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2.5rem]">
|
||||
{note.content?.substring(0, 80) || ''}
|
||||
{note.content && note.content.length > 80 && '...'}
|
||||
{(optimisticNote.content && typeof optimisticNote.content === 'string') ? optimisticNote.content.substring(0, 80) : ''}
|
||||
{optimisticNote.content && typeof optimisticNote.content === 'string' && optimisticNote.content.length > 80 && '...'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border">
|
||||
@@ -90,18 +241,19 @@ function CompactCard({
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{note.notebookId && (
|
||||
{optimisticNote.isPinned && (
|
||||
<Pin className="w-3 h-3 text-primary fill-current" />
|
||||
)}
|
||||
{optimisticNote.notebookId && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary dark:bg-primary/70" title={t('notes.inNotebook')} />
|
||||
)}
|
||||
{note.labels && note.labels.length > 0 && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" title={t('labels.count', { count: note.labels.length })} />
|
||||
{optimisticNote.labels && optimisticNote.labels.length > 0 && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" title={t('labels.count', { count: optimisticNote.labels.length })} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-3 right-3 w-2 h-2 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user