## Translation Files - Add 11 new language files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ missing translation keys across all 15 languages - New sections: notebook, pagination, ai.batchOrganization, ai.autoLabels - Update nav section with workspace, quickAccess, myLibrary keys ## Component Updates - Update 15+ components to use translation keys instead of hardcoded text - Components: notebook dialogs, sidebar, header, note-input, ghost-tags, etc. - Replace 80+ hardcoded English/French strings with t() calls - Ensure consistent UI across all supported languages ## Code Quality - Remove 77+ console.log statements from codebase - Clean up API routes, components, hooks, and services - Keep only essential error handling (no debugging logs) ## UI/UX Improvements - Update Keep logo to yellow post-it style (from-yellow-400 to-amber-500) - Change selection colors to #FEF3C6 (notebooks) and #EFB162 (nav items) - Make "+" button permanently visible in notebooks section - Fix grammar and syntax errors in multiple components ## Bug Fixes - Fix JSON syntax errors in it.json, nl.json, pl.json, zh.json - Fix syntax errors in notebook-suggestion-toast.tsx - Fix syntax errors in use-auto-tagging.ts - Fix syntax errors in paragraph-refactor.service.ts - Fix duplicate "fusion" section in nl.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Ou une version plus courte si vous préférez : feat(i18n): Add 15 languages, remove logs, update UI components - Create 11 new translation files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ translation keys: notebook, pagination, AI features - Update 15+ components to use translations (80+ strings) - Remove 77+ console.log statements from codebase - Fix JSON syntax errors in 4 translation files - Fix component syntax errors (toast, hooks, services) - Update logo to yellow post-it style - Change selection colors (#FEF3C6, #EFB162) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
299 lines
9.1 KiB
TypeScript
299 lines
9.1 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useRef, useCallback, memo, useMemo } from 'react';
|
|
import { Note } from '@/lib/types';
|
|
import { NoteCard } from './note-card';
|
|
import { NoteEditor } from './note-editor';
|
|
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
|
import { useResizeObserver } from '@/hooks/use-resize-observer';
|
|
import { useNotebookDrag } from '@/context/notebook-drag-context';
|
|
import { useLanguage } from '@/lib/i18n';
|
|
|
|
interface MasonryGridProps {
|
|
notes: Note[];
|
|
onEdit?: (note: Note, readOnly?: boolean) => void;
|
|
}
|
|
|
|
interface MasonryItemProps {
|
|
note: Note;
|
|
onEdit: (note: Note, readOnly?: boolean) => void;
|
|
onResize: () => void;
|
|
onDragStart?: (noteId: string) => void;
|
|
onDragEnd?: () => void;
|
|
isDragging?: boolean;
|
|
}
|
|
|
|
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, onDragStart, onDragEnd, isDragging }: MasonryItemProps) {
|
|
const resizeRef = useResizeObserver(onResize);
|
|
|
|
const sizeClasses = getSizeClasses(note.size);
|
|
|
|
return (
|
|
<div
|
|
className={`masonry-item absolute p-2 ${sizeClasses}`}
|
|
data-id={note.id}
|
|
ref={resizeRef as any}
|
|
>
|
|
<div className="masonry-item-content relative">
|
|
<NoteCard
|
|
note={note}
|
|
onEdit={onEdit}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
isDragging={isDragging}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}, (prev, next) => {
|
|
// Custom comparison to avoid re-render on function prop changes if note data is same
|
|
return prev.note.id === next.note.id && prev.note.order === next.note.order && prev.isDragging === next.isDragging;
|
|
});
|
|
|
|
export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
|
const { t } = useLanguage();
|
|
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
|
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
|
|
|
// Use external onEdit if provided, otherwise use internal state
|
|
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
|
|
if (onEdit) {
|
|
onEdit(note, readOnly);
|
|
} else {
|
|
setEditingNote({ note, readOnly });
|
|
}
|
|
}, [onEdit]);
|
|
|
|
const pinnedGridRef = useRef<HTMLDivElement>(null);
|
|
const othersGridRef = useRef<HTMLDivElement>(null);
|
|
const pinnedMuuri = useRef<any>(null);
|
|
const othersMuuri = useRef<any>(null);
|
|
|
|
// Memoize filtered and sorted notes to avoid recalculation on every render
|
|
const pinnedNotes = useMemo(
|
|
() => notes.filter(n => n.isPinned).sort((a, b) => a.order - b.order),
|
|
[notes]
|
|
);
|
|
const othersNotes = useMemo(
|
|
() => notes.filter(n => !n.isPinned).sort((a, b) => a.order - b.order),
|
|
[notes]
|
|
);
|
|
|
|
// CRITICAL: Sync editingNote when underlying note changes (e.g., after moving to notebook)
|
|
// This ensures the NoteEditor gets the updated note with the new notebookId
|
|
useEffect(() => {
|
|
if (!editingNote) return;
|
|
|
|
// Find the updated version of the currently edited note in the notes array
|
|
const updatedNote = notes.find(n => n.id === editingNote.note.id);
|
|
|
|
if (updatedNote) {
|
|
// Check if any key properties changed (especially notebookId)
|
|
const notebookIdChanged = updatedNote.notebookId !== editingNote.note.notebookId;
|
|
|
|
if (notebookIdChanged) {
|
|
// Update the editingNote with the new data
|
|
setEditingNote(prev => prev ? { ...prev, note: updatedNote } : null);
|
|
}
|
|
}
|
|
}, [notes, editingNote]);
|
|
|
|
const handleDragEnd = useCallback(async (grid: any) => {
|
|
if (!grid) return;
|
|
|
|
const items = grid.getItems();
|
|
const ids = items
|
|
.map((item: any) => item.getElement()?.getAttribute('data-id'))
|
|
.filter((id: any): id is string => !!id);
|
|
|
|
try {
|
|
// Save order to database WITHOUT revalidating the page
|
|
// Muuri has already updated the visual layout, so we don't need to reload
|
|
await updateFullOrderWithoutRevalidation(ids);
|
|
} catch (error) {
|
|
console.error('Failed to persist order:', error);
|
|
}
|
|
}, []);
|
|
|
|
const refreshLayout = useCallback(() => {
|
|
// Use requestAnimationFrame for smoother updates
|
|
requestAnimationFrame(() => {
|
|
if (pinnedMuuri.current) {
|
|
pinnedMuuri.current.refreshItems().layout();
|
|
}
|
|
if (othersMuuri.current) {
|
|
othersMuuri.current.refreshItems().layout();
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
// Initialize Muuri grids once on mount and sync when needed
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
let muuriInitialized = false;
|
|
|
|
const initMuuri = async () => {
|
|
// Prevent duplicate initialization
|
|
if (muuriInitialized) return;
|
|
muuriInitialized = true;
|
|
|
|
// Import web-animations-js polyfill
|
|
await import('web-animations-js');
|
|
// Dynamic import of Muuri to avoid SSR window error
|
|
const MuuriClass = (await import('muuri')).default;
|
|
|
|
if (!isMounted) return;
|
|
|
|
// Detect if we are on a touch device (mobile behavior)
|
|
const isMobile = window.matchMedia('(pointer: coarse)').matches;
|
|
|
|
const layoutOptions = {
|
|
dragEnabled: true,
|
|
// Always use specific drag handle to avoid conflicts
|
|
dragHandle: '.muuri-drag-handle',
|
|
dragContainer: document.body,
|
|
dragStartPredicate: {
|
|
distance: 10,
|
|
delay: 0,
|
|
},
|
|
dragPlaceholder: {
|
|
enabled: true,
|
|
createElement: (item: any) => {
|
|
const el = item.getElement().cloneNode(true);
|
|
el.style.opacity = '0.5';
|
|
return el;
|
|
},
|
|
},
|
|
dragAutoScroll: {
|
|
targets: [window],
|
|
speed: (item: any, target: any, intersection: any) => {
|
|
return intersection * 20;
|
|
},
|
|
},
|
|
};
|
|
|
|
// Initialize pinned grid
|
|
if (pinnedGridRef.current && !pinnedMuuri.current) {
|
|
pinnedMuuri.current = new MuuriClass(pinnedGridRef.current, layoutOptions)
|
|
.on('dragEnd', () => handleDragEnd(pinnedMuuri.current));
|
|
}
|
|
|
|
// Initialize others grid
|
|
if (othersGridRef.current && !othersMuuri.current) {
|
|
othersMuuri.current = new MuuriClass(othersGridRef.current, layoutOptions)
|
|
.on('dragEnd', () => handleDragEnd(othersMuuri.current));
|
|
}
|
|
};
|
|
|
|
initMuuri();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
pinnedMuuri.current?.destroy();
|
|
othersMuuri.current?.destroy();
|
|
pinnedMuuri.current = null;
|
|
othersMuuri.current = null;
|
|
};
|
|
// Only run once on mount
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Synchronize items when notes change (e.g. searching, adding)
|
|
useEffect(() => {
|
|
requestAnimationFrame(() => {
|
|
if (pinnedMuuri.current) {
|
|
pinnedMuuri.current.refreshItems().layout();
|
|
}
|
|
if (othersMuuri.current) {
|
|
othersMuuri.current.refreshItems().layout();
|
|
}
|
|
});
|
|
}, [notes]);
|
|
|
|
return (
|
|
<div className="masonry-container">
|
|
{pinnedNotes.length > 0 && (
|
|
<div className="mb-8">
|
|
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">{t('notes.pinned')}</h2>
|
|
<div ref={pinnedGridRef} className="relative min-h-[100px]">
|
|
{pinnedNotes.map(note => (
|
|
<MasonryItem
|
|
key={note.id}
|
|
note={note}
|
|
onEdit={handleEdit}
|
|
onResize={refreshLayout}
|
|
onDragStart={startDrag}
|
|
onDragEnd={endDrag}
|
|
isDragging={draggedNoteId === note.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{othersNotes.length > 0 && (
|
|
<div>
|
|
{pinnedNotes.length > 0 && (
|
|
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">{t('notes.others')}</h2>
|
|
)}
|
|
<div ref={othersGridRef} className="relative min-h-[100px]">
|
|
{othersNotes.map(note => (
|
|
<MasonryItem
|
|
key={note.id}
|
|
note={note}
|
|
onEdit={handleEdit}
|
|
onResize={refreshLayout}
|
|
onDragStart={startDrag}
|
|
onDragEnd={endDrag}
|
|
isDragging={draggedNoteId === note.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{editingNote && (
|
|
<NoteEditor
|
|
note={editingNote.note}
|
|
readOnly={editingNote.readOnly}
|
|
onClose={() => setEditingNote(null)}
|
|
/>
|
|
)}
|
|
|
|
<style jsx global>{`
|
|
.masonry-item {
|
|
display: block;
|
|
position: absolute;
|
|
z-index: 1;
|
|
}
|
|
.masonry-item.muuri-item-dragging {
|
|
z-index: 3;
|
|
}
|
|
.masonry-item.muuri-item-releasing {
|
|
z-index: 2;
|
|
}
|
|
.masonry-item.muuri-item-hidden {
|
|
z-index: 0;
|
|
}
|
|
.masonry-item-content {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|