"use client"; import { useEffect, useState, useRef } from "react"; import { useSession } from "next-auth/react"; import { redirect } from "next/navigation"; import Navigation from "@/components/carnet/navigation"; import { NotesView } from "@/components/carnet/notes-view"; import { Editor } from "@/components/carnet/editor"; import { PanelResizer } from "@/components/carnet/panel-resizer"; import { useMediaQuery } from "@/hooks/use-media-query"; import { ContactsView } from '@/components/carnet/contacts-view'; import { MissionsView } from '@/components/carnet/missions-view'; import { MissionFilesView } from '@/components/carnet/mission-files-view'; import { MissionFilesManager } from '@/components/carnet/mission-files-manager'; import { X, Menu } from "lucide-react"; import { ContactDetails } from '@/components/carnet/contact-details'; import { parse as parseVCard, format as formatVCard } from 'vcard-parser'; import { format } from 'date-fns'; import { fr } from 'date-fns/locale'; import { PaneLayout } from './pane-layout'; import { notesCache, noteContentCache, foldersCache, invalidateFolderCache, invalidateNoteCache } from '@/lib/cache-utils'; interface Note { id: string; title: string; content: string; lastModified: string; type: string; mime: string; etag: string; } interface Contact { id: string; fullName?: string; email?: string; phone?: string; organization?: string; address?: string; notes?: string; group?: string; } export default function CarnetPage() { const { data: session, status } = useSession(); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [layoutMode, setLayoutMode] = useState("item-selection"); const [selectedNote, setSelectedNote] = useState(null); const [isMobile, setIsMobile] = useState(false); const [showNav, setShowNav] = useState(true); const [showNotes, setShowNotes] = useState(true); const [nextcloudFolders, setNextcloudFolders] = useState([]); const [selectedFolder, setSelectedFolder] = useState('Notes'); const [notes, setNotes] = useState([]); const [isLoadingNotes, setIsLoadingNotes] = useState(true); const [contacts, setContacts] = useState([]); const [selectedContact, setSelectedContact] = useState(null); const [isLoadingContacts, setIsLoadingContacts] = useState(true); const [selectedMission, setSelectedMission] = useState<{ id: string; name: string; creatorId?: string; isClosed?: boolean; missionUsers?: any[]; } | null>(null); const [selectedMissionFile, setSelectedMissionFile] = useState<{ key: string; content: string } | null>(null); // Panel widths state const [navWidth, setNavWidth] = useState(220); const [notesWidth, setNotesWidth] = useState(400); const [isDraggingNav, setIsDraggingNav] = useState(false); const [isDraggingNotes, setIsDraggingNotes] = useState(false); // Check screen size const isSmallScreen = useMediaQuery("(max-width: 768px)"); const isMediumScreen = useMediaQuery("(max-width: 1024px)"); // Cache is now managed by cache-utils.ts useEffect(() => { const fetchNextcloudFolders = async () => { // Check cache first const cacheKey = session?.user?.id || 'default'; const cachedFolders = foldersCache.get(cacheKey); if (cachedFolders) { setNextcloudFolders(cachedFolders); return; } try { const response = await fetch('/api/storage/status'); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || 'Failed to fetch storage folders'); } const data = await response.json(); const folders = data.folders || []; // Update cache foldersCache.set(cacheKey, folders); setNextcloudFolders(folders); } catch (err) { console.error('Error fetching storage folders:', err); setNextcloudFolders([]); } }; if (status === "authenticated") { fetchNextcloudFolders(); } }, [status, session?.user?.id]); useEffect(() => { if (status === "unauthenticated") { redirect("/signin"); } if (status === "authenticated" && session?.user?.id) { // Initialize all folders when user logs in fetch('/api/storage/init', { method: 'POST', headers: { 'Content-Type': 'application/json' } }).then(response => { if (response.ok) { console.log('All folders initialized successfully'); } else { console.error('Failed to initialize folders'); } }).catch(error => { console.error('Error initializing folders:', error); }); } if (status !== "loading") { setIsLoading(false); } }, [status, session?.user?.id]); useEffect(() => { if (isSmallScreen) { setIsMobile(true); setShowNav(false); setShowNotes(false); } else if (isMediumScreen) { setIsMobile(false); setShowNav(true); setShowNotes(false); } else { setIsMobile(false); setShowNav(true); setShowNotes(true); } }, [isSmallScreen, isMediumScreen]); useEffect(() => { console.log(`[useEffect] selectedFolder changed to: "${selectedFolder}"`); if (selectedFolder === 'Contacts') { // When "Contacts" is selected, show all contacts console.log(`[useEffect] Calling fetchContacts('Contacts')`); fetchContacts('Contacts'); } else if (selectedFolder.endsWith('.vcf')) { // When a specific VCF file is selected, show its contacts console.log(`[useEffect] Calling fetchContacts('${selectedFolder}')`); fetchContacts(selectedFolder); } else { // For other folders (Notes, etc.), fetch notes console.log(`[useEffect] Calling fetchNotes() for folder: ${selectedFolder}`); fetchNotes(); } }, [selectedFolder, session?.user?.id]); // Listen for note-saved events to refresh the list useEffect(() => { const handleNoteSaved = (event: CustomEvent) => { const folder = event.detail?.folder; if (folder && session?.user?.id) { console.log('Note saved event received, invalidating cache for folder:', folder); // Invalidate cache for this folder invalidateFolderCache(session.user.id, folder); // Fetch notes if this is the current folder if (selectedFolder.toLowerCase() === folder.toLowerCase()) { fetchNotes(); } } }; window.addEventListener('note-saved', handleNoteSaved as EventListener); return () => { window.removeEventListener('note-saved', handleNoteSaved as EventListener); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedFolder, session?.user?.id]); const parseVCardContent = (content: string): Contact[] => { try { console.log(`[parseVCardContent] Parsing VCF content, length: ${content.length}`); // Normalize line endings and split vCards // Replace \r\n with \n, then split by BEGIN:VCARD const normalizedContent = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); // Split the content into individual vCards // Handle formats like: BEGIN:VCARD\n... or BEGIN:VCARDVERSION:3.0... const vcardSections = normalizedContent.split(/BEGIN:VCARD/i).filter(section => section.trim()); console.log(`[parseVCardContent] Found ${vcardSections.length} vCard sections`); const parsed = vcardSections.map((section, index) => { try { // Reconstruct vCard content with proper formatting let vcardContent = 'BEGIN:VCARD\n'; // Clean up the section and ensure proper line breaks let cleanedSection = section.trim(); // Fix format where BEGIN:VCARD is directly followed by VERSION (no newline) cleanedSection = cleanedSection.replace(/^VERSION:/, 'VERSION:'); // Ensure each field is on its own line cleanedSection = cleanedSection .replace(/([^\n])(VERSION:)/g, '$1\n$2') .replace(/([^\n])(UID:)/g, '$1\n$2') .replace(/([^\n])(FN:)/g, '$1\n$2') .replace(/([^\n])(EMAIL)/g, '$1\n$2') .replace(/([^\n])(TEL)/g, '$1\n$2') .replace(/([^\n])(ORG:)/g, '$1\n$2') .replace(/([^\n])(NOTE:)/g, '$1\n$2') .replace(/([^\n])(END:VCARD)/g, '$1\n$2'); vcardContent += cleanedSection; // Ensure it ends with END:VCARD if (!vcardContent.trim().endsWith('END:VCARD')) { vcardContent += '\nEND:VCARD'; } console.log(`[parseVCardContent] Processing vCard ${index + 1}, content preview:`, vcardContent.substring(0, 300)); const vcard = parseVCard(vcardContent); // Extract contact properties with proper type handling const uid = vcard.uid?.[0]?.value; let fullName = vcard.fn?.[0]?.value; // If FN is not available, try to construct name from N field (Family;Given;Additional;Prefix;Suffix) if (!fullName && vcard.n?.[0]?.value) { const nameParts = vcard.n[0].value.split(';').filter(Boolean); // N format: Family;Given;Additional;Prefix;Suffix // Construct: Given Family or Family Given if (nameParts.length >= 2) { fullName = `${nameParts[1]} ${nameParts[0]}`.trim() || nameParts[0] || nameParts[1]; } else if (nameParts.length === 1) { fullName = nameParts[0]; } } // Fallback: try to extract from raw content if parser didn't work if (!fullName) { const fnMatch = section.match(/FN[;:]?([^\r\n]+)/i); if (fnMatch) { fullName = fnMatch[1].trim(); } else { const nMatch = section.match(/N[;:]?([^\r\n]+)/i); if (nMatch) { const nameParts = nMatch[1].split(';').filter(Boolean); if (nameParts.length >= 2) { fullName = `${nameParts[1]} ${nameParts[0]}`.trim() || nameParts[0] || nameParts[1]; } else if (nameParts.length === 1) { fullName = nameParts[0].trim(); } } } } const email = vcard.email?.[0]?.value || vcard.email?.[0]; const phone = vcard.tel?.[0]?.value || vcard.tel?.[0]; const organization = vcard.org?.[0]?.value || vcard.org?.[0]; const address = vcard.adr?.[0]?.value || (vcard.adr?.[0] ? JSON.stringify(vcard.adr[0]) : ''); const notes = vcard.note?.[0]?.value || vcard.note?.[0]; const group = vcard.categories?.[0]?.value || vcard.categories?.[0]; console.log(`[parseVCardContent] Parsed contact ${index + 1}:`, { uid, fullName, email, phone, rawVCard: vcard }); // Create a clean contact object const contact: Contact = { id: uid || Math.random().toString(36).substr(2, 9), fullName: fullName || email || 'Sans nom', email: email || '', phone: phone || '', organization: organization || '', address: typeof address === 'string' ? address : '', notes: notes || '', group: group || '' }; return contact; } catch (parseError) { console.error(`[parseVCardContent] Error parsing vCard section ${index + 1}:`, parseError); return null; } }).filter((contact): contact is Contact => contact !== null); console.log(`[parseVCardContent] Successfully parsed ${parsed.length} contacts`); return parsed; } catch (error) { console.error('[parseVCardContent] Error parsing VCF content:', error); return []; } }; const fetchContacts = async (folder: string) => { console.log(`[fetchContacts] Called with folder: ${folder}`); try { setIsLoadingContacts(true); // Use lowercase for consistency const folderLowercase = folder.toLowerCase(); console.log(`[fetchContacts] Processing folder: ${folderLowercase}`); // First, check if we're looking at a specific VCF file if (folder.endsWith('.vcf')) { const response = await fetch(`/api/storage/files/content?path=${encodeURIComponent(`user-${session?.user?.id}/${folderLowercase}/${folder}`)}`); if (response.ok) { const { content } = await response.json(); const contacts = parseVCardContent(content); setContacts(contacts.map(contact => ({ ...contact, group: folder.replace('.vcf', '') }))); } } else { // If not a VCF file, list all VCF files in the folder console.log(`[fetchContacts] Fetching files from API for folder: ${folderLowercase}`); const response = await fetch(`/api/storage/files?folder=${folderLowercase}`); console.log(`[fetchContacts] API response status: ${response.status}`); if (response.ok) { const files = await response.json(); console.log(`[fetchContacts] Found ${files.length} files in contacts folder`, files); // Filter VCF files - API returns { key, name, size, lastModified } const vcfFiles = files.filter((file: any) => file.name?.endsWith('.vcf') || file.key?.endsWith('.vcf') ); console.log(`[fetchContacts] Found ${vcfFiles.length} VCF files`, vcfFiles); // Parse VCF files and extract contact information const parsedContacts = await Promise.all( vcfFiles.map(async (file: any) => { try { // Use file.key (S3 key) or file.id as the path const fileKey = file.key || file.id; console.log(`[fetchContacts] Fetching content for VCF file: ${fileKey}`); const contentResponse = await fetch(`/api/storage/files/content?path=${encodeURIComponent(fileKey)}`); if (contentResponse.ok) { const { content } = await contentResponse.json(); console.log(`[fetchContacts] VCF content preview (first 500 chars):`, content.substring(0, 500)); const contacts = parseVCardContent(content); console.log(`[fetchContacts] Parsed ${contacts.length} contacts from ${fileKey}`, contacts); return contacts.map(contact => ({ ...contact, group: (file.name || file.key?.split('/').pop() || 'contacts')?.replace('.vcf', '') })); } else { console.error(`[fetchContacts] Failed to fetch content for ${fileKey}:`, contentResponse.status); } return []; } catch (error) { console.error('Error fetching VCF content:', error); return []; } }) ); // Flatten the array of contact arrays const allContacts = parsedContacts.flat().filter(Boolean); console.log(`[fetchContacts] Total contacts parsed: ${allContacts.length}`, allContacts); setContacts(allContacts); // Log state after setting setTimeout(() => { console.log(`[fetchContacts] Contacts state after setContacts:`, allContacts.length); }, 100); } else { console.error(`[fetchContacts] Failed to fetch files:`, response.status, await response.text().catch(() => '')); setContacts([]); } } } catch (error) { console.error('[fetchContacts] Error fetching contacts:', error); setContacts([]); } finally { setIsLoadingContacts(false); console.log(`[fetchContacts] Finished loading contacts, isLoadingContacts set to false`); } }; // Fetch notes based on the selected folder const fetchNotes = async (skipCache = false) => { if (!session?.user?.id) { setIsLoadingNotes(false); return; } try { setIsLoadingNotes(true); // Convert folder name to lowercase for consistent storage access const folderLowercase = selectedFolder.toLowerCase(); console.log(`[fetchNotes] Fetching notes from folder: ${folderLowercase}, skipCache: ${skipCache}`); // Check cache first (unless skipCache is true) const cacheKey = `${session.user.id}-${folderLowercase}`; if (!skipCache) { const cachedNotes = notesCache.get(cacheKey); if (cachedNotes) { console.log(`[fetchNotes] Using cached notes for ${folderLowercase} folder (${cachedNotes.length} notes)`); setNotes(cachedNotes); setIsLoadingNotes(false); return; } else { console.log(`[fetchNotes] No cache found for ${folderLowercase} folder, fetching from API`); } } else { console.log(`[fetchNotes] Skipping cache for ${folderLowercase} folder (skipCache=true)`); } // Fetch from API const response = await fetch(`/api/storage/files?folder=${folderLowercase}`); if (response.ok) { const data = await response.json(); console.log(`[fetchNotes] Fetched ${data.length} files from ${folderLowercase} folder`, data); // Map API response to Note format // API returns: { key, name, size, lastModified } // Frontend expects: { id, title, content, lastModified, type, mime, etag } const mappedNotes: Note[] = data.map((file: any) => { // Extract title from filename (remove .md extension if present) let title = file.name?.replace(/\.md$/, '') || file.name || 'Untitled'; // For Diary/Health folders, convert sanitized filename back to formatted title // Example: "16_janvier_2026" -> "16 janvier 2026" if ((folderLowercase === 'diary' || folderLowercase === 'health') && title.includes('_')) { // Check if it matches the date pattern: DD_mois_YYYY const datePattern = /^(\d{1,2})_([a-zéèêà]+)_(\d{4})$/i; const match = title.match(datePattern); if (match) { const day = match[1]; const month = match[2]; const year = match[3]; // Capitalize first letter of month const monthCapitalized = month.charAt(0).toUpperCase() + month.slice(1); title = `${day} ${monthCapitalized} ${year}`; } else { // If pattern doesn't match, just replace underscores with spaces title = title.replace(/_/g, ' '); } } return { id: file.key || file.id || '', title: title, content: '', // Content will be loaded when note is selected lastModified: file.lastModified ? new Date(file.lastModified).toISOString() : new Date().toISOString(), type: 'file', mime: file.name?.endsWith('.md') ? 'text/markdown' : 'text/plain', etag: file.key || '' // Use key as etag for now }; }); // Remove duplicates based on id const uniqueNotes = mappedNotes.filter((note, index, self) => index === self.findIndex(n => n.id === note.id) ); console.log(`[fetchNotes] Mapped ${uniqueNotes.length} unique notes from ${folderLowercase} folder (${mappedNotes.length} total before deduplication)`); console.log(`[fetchNotes] Note titles:`, uniqueNotes.map(n => n.title)); // Update state setNotes(uniqueNotes); // Update cache notesCache.set(cacheKey, uniqueNotes); console.log(`[fetchNotes] Cache updated for ${folderLowercase} folder with ${uniqueNotes.length} notes`); } else { const errorData = await response.json().catch(() => ({})); console.error('[fetchNotes] Error fetching notes:', errorData.message || response.statusText); setNotes([]); } } catch (error) { console.error('[fetchNotes] Error fetching notes:', error); setNotes([]); } finally { setIsLoadingNotes(false); } }; // Handle saving changes to a note const handleSaveNote = async (note: Note) => { try { setIsSaving(true); // For Diary and Health, ensure title is formatted with date let noteTitle = note.title || 'Untitled'; let fileKey: string | undefined; if (selectedFolder === 'Diary' || selectedFolder === 'Health') { // For Diary/Health, always use today's date as title (formatted for display) const today = new Date(); const dateStr = format(today, 'yyyy-MM-dd'); // For filename matching const dateTitle = format(today, 'd MMMM yyyy', { locale: fr }); // For display: "16 janvier 2026" noteTitle = dateTitle; // If note.id is already set (from handleNewNote), use it directly // This ensures we use the same fileKey that was pre-set if (note.id && note.id.includes(`user-${session?.user?.id}/${selectedFolder.toLowerCase()}/`)) { fileKey = note.id; console.log(`[handleSaveNote] Using pre-set fileKey from note.id: ${fileKey}`); } else { // ALWAYS check if a note with this date already exists // This ensures we update the existing note instead of creating a duplicate console.log(`[handleSaveNote] Checking for existing note. Date: ${dateTitle}, DateStr: ${dateStr}, Notes count: ${notes.length}`); const existingNote = notes.find(n => { const title = n.title || ''; const noteId = n.id || ''; // Check multiple patterns to find the existing note return title === dateTitle || title === dateStr || title.startsWith(dateStr) || title.startsWith(dateTitle) || noteId.includes(dateStr) || noteId.includes(dateTitle.replace(/\s/g, '_')) || noteId.includes(dateTitle.replace(/\s/g, '-')); }); if (existingNote) { // Update the existing note instead of creating a new one console.log(`[handleSaveNote] Found existing note for today, updating: ${existingNote.id}, title: ${existingNote.title}`); note.id = existingNote.id; noteTitle = existingNote.title || dateTitle; // Keep the existing title format // Use the existing note's id as the fileKey - this is critical! fileKey = existingNote.id; console.log(`[handleSaveNote] Using existing fileKey: ${fileKey}`); } else { // No existing note found, create new one with formatted date title // Sanitize the title to match the format used in S3 const sanitizedTitle = dateTitle.replace(/[^a-zA-Z0-9._-]/g, '_'); fileKey = `user-${session?.user?.id}/${selectedFolder.toLowerCase()}/${sanitizedTitle}.md`; console.log(`[handleSaveNote] No existing note found, creating new with fileKey: ${fileKey}`); } } } else { // For other folders (Bloc-notes) console.log(`[handleSaveNote] BLOC-NOTES: Starting save process`); console.log(`[handleSaveNote] BLOC-NOTES: note.id = "${note.id}"`); console.log(`[handleSaveNote] BLOC-NOTES: noteTitle = "${noteTitle}"`); console.log(`[handleSaveNote] BLOC-NOTES: note.title = "${note.title}"`); console.log(`[handleSaveNote] BLOC-NOTES: Current notes count: ${notes.length}`); console.log(`[handleSaveNote] BLOC-NOTES: Current notes:`, notes.map(n => ({ id: n.id, title: n.title }))); // Refresh notes list to ensure we have the latest data console.log(`[handleSaveNote] BLOC-NOTES: Refreshing notes list...`); await fetchNotes(true); console.log(`[handleSaveNote] BLOC-NOTES: After refresh, notes count: ${notes.length}`); console.log(`[handleSaveNote] BLOC-NOTES: After refresh, notes:`, notes.map(n => ({ id: n.id, title: n.title }))); // Check if the current note.id corresponds to an "Untitled" note // and if we're giving it a new title (renaming scenario) const isRenamingUntitled = note.id && !note.id.startsWith('temp-') && note.id.includes(`user-${session?.user?.id}/`) && note.id.includes('/Untitled.md') && noteTitle && noteTitle.trim() !== '' && noteTitle !== 'Untitled'; console.log(`[handleSaveNote] BLOC-NOTES: isRenamingUntitled = ${isRenamingUntitled}`); if (isRenamingUntitled) { console.log(`[handleSaveNote] BLOC-NOTES: Detected renaming scenario`); } if (isRenamingUntitled) { // We're renaming an Untitled note to a new title const sanitizedTitle = noteTitle.replace(/[^a-zA-Z0-9._-]/g, '_'); fileKey = `user-${session?.user?.id}/${selectedFolder.toLowerCase()}/${sanitizedTitle}.md`; console.log(`[handleSaveNote] BLOC-NOTES: Renaming Untitled note to "${noteTitle}": ${note.id} -> ${fileKey}`); // Store the old id to delete it later (note as any)._oldUntitledId = note.id; note.id = fileKey; // Update note.id to the new fileKey } else if (note.id && !note.id.startsWith('temp-') && note.id.includes(`user-${session?.user?.id}/`)) { // Note has a valid id and we're not renaming, use it directly fileKey = note.id; console.log(`[handleSaveNote] BLOC-NOTES: Using existing note id directly: ${fileKey}`); } else { console.log(`[handleSaveNote] BLOC-NOTES: Entering else branch (new note or temp id)`); // This is a new note or note with temp id // Strategy: If we have a title, check for existing note with that title // If no title or title is "Untitled", check for existing "Untitled" note // If we're updating from "Untitled" to a real title, find the "Untitled" note and update it if (noteTitle && noteTitle.trim() !== '' && noteTitle !== 'Untitled') { // Note has a real title, check if a note with this title already exists console.log(`[handleSaveNote] BLOC-NOTES: Note has real title: "${noteTitle}"`); const sanitizedTitle = noteTitle.replace(/[^a-zA-Z0-9._-]/g, '_'); const expectedFileKey = `user-${session?.user?.id}/${selectedFolder.toLowerCase()}/${sanitizedTitle}.md`; console.log(`[handleSaveNote] BLOC-NOTES: Expected fileKey: ${expectedFileKey}`); const existingNoteWithTitle = notes.find(n => { const nTitle = n.title || ''; const nId = n.id || ''; // Check if title matches exactly or if the id matches the expected fileKey const matches = (nTitle === noteTitle && nTitle !== 'Untitled') || nId === expectedFileKey; if (matches) { console.log(`[handleSaveNote] BLOC-NOTES: Found matching note:`, { id: nId, title: nTitle }); } return matches; }); if (existingNoteWithTitle) { // Update the existing note with this title console.log(`[handleSaveNote] BLOC-NOTES: Found existing note with title "${noteTitle}", updating: ${existingNoteWithTitle.id}`); note.id = existingNoteWithTitle.id; fileKey = existingNoteWithTitle.id; } else { // Check if there's an "Untitled" note we should update (user added title to Untitled note) console.log(`[handleSaveNote] BLOC-NOTES: No note with title "${noteTitle}" found, checking for Untitled note...`); const untitledNote = notes.find(n => { const nTitle = n.title || ''; const isUntitled = (nTitle === 'Untitled' || nTitle === '' || nTitle.trim() === '') && n.id && n.id.includes(`user-${session?.user?.id}/${selectedFolder.toLowerCase()}/`); if (isUntitled) { console.log(`[handleSaveNote] BLOC-NOTES: Found Untitled note:`, { id: n.id, title: nTitle }); } return isUntitled; }); if (untitledNote) { // Rename the Untitled note with the new title // Use the new fileKey based on the new title console.log(`[handleSaveNote] BLOC-NOTES: Renaming Untitled note to "${noteTitle}": ${untitledNote.id} -> ${expectedFileKey}`); // Store the old id to delete it later const oldUntitledId = untitledNote.id; fileKey = expectedFileKey; note.id = fileKey; // Update note.id to the new fileKey // We'll delete the old Untitled file after saving // Store it in a variable accessible after the save (note as any)._oldUntitledId = oldUntitledId; } else { // No existing note found, create new one fileKey = expectedFileKey; console.log(`[handleSaveNote] BLOC-NOTES: No existing note found, creating new one with fileKey: ${fileKey}`); } } } else { console.log(`[handleSaveNote] BLOC-NOTES: Note has no title or title is "Untitled"`); // No title or title is "Untitled", check for existing Untitled note console.log(`[handleSaveNote] BLOC-NOTES: Searching for existing Untitled note...`); const untitledNote = notes.find(n => { const nTitle = n.title || ''; const isUntitled = (nTitle === 'Untitled' || nTitle === '' || nTitle.trim() === '') && n.id && n.id.includes(`user-${session?.user?.id}/${selectedFolder.toLowerCase()}/`); if (isUntitled) { console.log(`[handleSaveNote] BLOC-NOTES: Found Untitled note:`, { id: n.id, title: nTitle }); } return isUntitled; }); if (untitledNote) { // Use existing Untitled note console.log(`[handleSaveNote] BLOC-NOTES: Using existing Untitled note: ${untitledNote.id}`); note.id = untitledNote.id; noteTitle = 'Untitled'; fileKey = untitledNote.id; } else { // Create new Untitled note fileKey = `user-${session?.user?.id}/${selectedFolder.toLowerCase()}/Untitled.md`; console.log(`[handleSaveNote] BLOC-NOTES: No Untitled note found, creating new one: ${fileKey}`); } } } } if (!fileKey) { throw new Error('Cannot determine file key for saving note'); } // Construct API payload with lowercase folder name const payload = { id: fileKey, title: noteTitle, content: note.content || '', folder: selectedFolder.toLowerCase(), // Use lowercase for storage consistency mime: "text/markdown" }; console.log(`[handleSaveNote] File key for ${selectedFolder}: ${fileKey}`); console.log(`[handleSaveNote] Payload content length: ${payload.content.length} chars`); if (selectedFolder === 'Health') { console.log(`[handleSaveNote] Health payload content preview:`, payload.content.substring(0, 200)); } // Use direct storage API endpoint const endpoint = '/api/storage/files'; // For Diary/Health, always use PUT (we always have a fileKey, either from existing note or constructed) // For other folders, use PUT if note.id exists, otherwise POST const method = (selectedFolder === 'Diary' || selectedFolder === 'Health') ? 'PUT' // Always PUT for Diary/Health since we always have a known fileKey : (note.id ? 'PUT' : 'POST'); console.log(`Saving note to ${selectedFolder.toLowerCase()} using ${method}, title: ${noteTitle}, fileKey: ${fileKey}`); const response = await fetch(endpoint, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); console.log(`[handleSaveNote] Response status: ${response.status}, ok: ${response.ok}`); if (response.ok) { console.log(`[handleSaveNote] Save successful for ${selectedFolder}`); // If we renamed a note from Untitled to a title, delete the old Untitled file const oldUntitledId = (note as any)._oldUntitledId; if (oldUntitledId && oldUntitledId !== fileKey) { try { const deleteResponse = await fetch(`/api/storage/files`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: oldUntitledId, folder: selectedFolder.toLowerCase() }) }); if (deleteResponse.ok) { console.log(`[handleSaveNote] Deleted old Untitled file: ${oldUntitledId}`); } else { console.warn(`[handleSaveNote] Failed to delete old Untitled file: ${oldUntitledId}`); } } catch (deleteError) { console.error('Error deleting old Untitled file:', deleteError); // Continue anyway, the new file was created successfully } } // Invalidate the cache for this folder to ensure fresh data on next fetch if (session?.user?.id) { invalidateFolderCache(session.user.id, selectedFolder); } // Update the content cache for this note if (payload.id) { noteContentCache.set(payload.id, payload.content); } // For Health folder, update selectedNote with the new content so the form reflects the saved data if (selectedFolder === 'Health' && selectedNote?.id === fileKey) { console.log(`[handleSaveNote] Updating selectedNote content for Health form`); setSelectedNote({ ...selectedNote, content: payload.content, lastModified: new Date().toISOString() }); } // Refresh the list of notes (skip cache to get fresh data) // Only refresh if not Health folder to avoid constant flickering during form updates // Health folder updates are handled by the form itself and don't need immediate list refresh if (selectedFolder !== 'Health') { fetchNotes(true); } else { console.log(`[handleSaveNote] Health folder - skipping fetchNotes to avoid flickering`); } // Log success for debugging - try to read response but don't fail if it's empty try { const responseText = await response.text(); if (responseText) { const responseData = JSON.parse(responseText); console.log(`[handleSaveNote] Save successful, response:`, responseData); } else { console.log(`[handleSaveNote] Save successful (empty response)`); } } catch (parseError) { console.log(`[handleSaveNote] Save successful (could not parse response)`); } } else { const errorText = await response.text().catch(() => ''); let errorData: { message?: string; error?: string } = {}; try { errorData = errorText ? JSON.parse(errorText) : {}; } catch { // Not JSON, use as plain text } const errorMessage = errorData.message || errorData.error || errorText || `Failed to save note: ${response.status} ${response.statusText}`; console.error(`[handleSaveNote] Error saving note (${response.status}):`, errorMessage); throw new Error(errorMessage); } } catch (error) { console.error('Error saving note:', error); } finally { setIsSaving(false); } }; // Handle panel resizing const handleNavResize = (e: MouseEvent) => { if (!isDraggingNav) return; const newWidth = e.clientX; if (newWidth >= 48 && newWidth <= 400) { setNavWidth(newWidth); } }; const handleNotesResize = (e: MouseEvent) => { if (!isDraggingNotes) return; const newWidth = e.clientX - navWidth - 2; // 2px for the resizer if (newWidth >= 200) { setNotesWidth(newWidth); } }; const handleNoteSelect = (note: Note) => { setSelectedNote(note); if (isMobile) { setShowNotes(false); } }; const handleNoteSave = async (note: Note) => { try { const endpoint = '/api/storage/files'; const method = note.id ? 'PUT' : 'POST'; const response = await fetch(endpoint, { method, headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: note.id, title: note.title, content: note.content, folder: selectedFolder.toLowerCase() }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to save note: ${errorText}`); } // After successful save, refresh the notes list const notesResponse = await fetch(`/api/storage/files?folder=${selectedFolder.toLowerCase()}`); if (notesResponse.ok) { const updatedNotes = await notesResponse.json(); setNotes(updatedNotes); } } catch (error) { console.error('Error saving note:', error); throw error; // Re-throw pour permettre la gestion d'erreur en amont } }; const handleFolderSelect = async (folder: string) => { console.log('Selected folder:', folder); setSelectedFolder(folder); setLayoutMode("item-selection"); // Reset selected note, contact, and mission when changing folders setSelectedNote(null); setSelectedContact(null); setSelectedMission(null); setSelectedMissionFile(null); // For Missions, don't create folder structure if (folder === 'Missions') { return; } // Ensure folder exists in storage before fetching try { // Create the folder if it doesn't exist const lowerFolder = folder.toLowerCase(); console.log(`Ensuring folder exists: ${lowerFolder}`); const response = await fetch(`/api/storage/init/folder`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder: lowerFolder }) }); if (!response.ok) { console.warn(`Failed to create folder ${lowerFolder}: ${await response.text()}`); } } catch (error) { console.error('Error creating folder:', error); } }; const handleContactSelect = (contact: Contact) => { setSelectedContact(contact); if (isMobile) { setShowNotes(false); } }; const handleMissionSelect = async (mission: { id: string; name: string }) => { // Fetch full mission details including creator and missionUsers try { const response = await fetch(`/api/missions/${mission.id}`); if (response.ok) { const missionData = await response.json(); setSelectedMission({ id: missionData.id, name: missionData.name, creatorId: missionData.creatorId || missionData.creator?.id, isClosed: missionData.isClosed || false, missionUsers: missionData.missionUsers || [] }); } else { // Fallback to basic mission data setSelectedMission(mission); } } catch (error) { console.error('Error fetching mission details:', error); setSelectedMission(mission); } setSelectedMissionFile(null); }; const handleMissionFileSelect = async (file: { key: string; name: string; path: string }) => { if (!selectedMission) return; // Check file extension to determine if it's a text file const fileName = file.name.toLowerCase(); const textExtensions = ['.md', '.txt', '.json', '.yaml', '.yml', '.csv', '.log', '.conf', '.config', '.ini', '.env']; const isTextFile = textExtensions.some(ext => fileName.endsWith(ext)); // For binary files (PDF, images, etc.), download them instead if (!isTextFile) { try { // Get the file URL for download const fileUrl = `/api/missions/image/${file.key}`; // Open in new tab or download window.open(fileUrl, '_blank'); return; } catch (error) { console.error('Error opening file:', error); return; } } // For text files, fetch and open in editor try { const response = await fetch(`/api/missions/${selectedMission.id}/files`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: file.key }) }); if (response.ok) { const data = await response.json(); setSelectedMissionFile({ key: file.key, content: data.content || '' }); } } catch (error) { console.error('Error fetching mission file content:', error); } }; const handleMissionFileSave = async (content: string) => { if (!selectedMission || !selectedMissionFile) return; try { const response = await fetch(`/api/missions/${selectedMission.id}/files`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: selectedMissionFile.key, content }) }); if (!response.ok) { throw new Error('Failed to save file'); } } catch (error) { console.error('Error saving mission file:', error); throw error; } }; const handleNewNote = async () => { // For Diary and Health folders, check if a note exists for today if (selectedFolder === 'Diary' || selectedFolder === 'Health') { const today = new Date(); const dateStr = format(today, 'yyyy-MM-dd'); // For filename matching const dateTitle = format(today, 'd MMMM yyyy', { locale: fr }); // For display: "16 janvier 2026" // Refresh notes list first to ensure we have the latest data await fetchNotes(true); // Check if a note with this date already exists // We check both the date string (YYYY-MM-DD) and the formatted title const existingNote = notes.find(note => { const title = note.title || ''; const noteId = note.id || ''; // Check multiple patterns to find the existing note return title === dateTitle || title === dateStr || title.startsWith(dateStr) || title.startsWith(dateTitle) || noteId.includes(dateStr) || noteId.includes(dateTitle.replace(/\s/g, '_')) || noteId.includes(dateTitle.replace(/\s/g, '-')); }); if (existingNote) { // Open the existing note for today console.log(`[handleNewNote] Found existing note for today: ${existingNote.title}, id: ${existingNote.id}`); handleNoteSelect(existingNote); return; } // Create a new note with today's date as title (formatted for display) // Set the id to the expected fileKey so handleSaveNote will use it const expectedFileKey = `user-${session?.user?.id}/${selectedFolder.toLowerCase()}/${dateTitle.replace(/\s/g, '_')}.md`; const newNote: Note = { id: expectedFileKey, // Pre-set the id to the expected fileKey title: dateTitle, // Use formatted date for display: "16 janvier 2026" content: '', lastModified: today.toISOString(), type: 'file', mime: 'text/markdown', etag: '' }; console.log(`[handleNewNote] Creating new note for today with id: ${expectedFileKey}`); setSelectedNote(newNote); if (isMobile) { setShowNotes(false); } } else { // For other folders (Bloc-notes) console.log(`[handleNewNote] BLOC-NOTES: Starting new note creation`); console.log(`[handleNewNote] BLOC-NOTES: Current notes count: ${notes.length}`); console.log(`[handleNewNote] BLOC-NOTES: Current notes:`, notes.map(n => ({ id: n.id, title: n.title }))); // Refresh notes list first console.log(`[handleNewNote] BLOC-NOTES: Refreshing notes list...`); await fetchNotes(true); console.log(`[handleNewNote] BLOC-NOTES: After refresh, notes count: ${notes.length}`); console.log(`[handleNewNote] BLOC-NOTES: After refresh, notes:`, notes.map(n => ({ id: n.id, title: n.title }))); // Check if "Untitled" already exists const untitledNote = notes.find(note => { const title = note.title || ''; const isUntitled = title === 'Untitled' || title === '' || title.trim() === ''; if (isUntitled) { console.log(`[handleNewNote] BLOC-NOTES: Found potential Untitled note:`, { id: note.id, title: note.title }); } return isUntitled; }); if (untitledNote) { // Open the existing untitled note console.log(`[handleNewNote] BLOC-NOTES: Found existing untitled note, opening it:`, { id: untitledNote.id, title: untitledNote.title }); handleNoteSelect(untitledNote); return; } // Create a blank note with a temporary unique id to track it // This id will be used in handleSaveNote to check for duplicates const tempId = `temp-${Date.now()}`; console.log(`[handleNewNote] BLOC-NOTES: No existing Untitled note found, creating new note with temp id: ${tempId}`); const newNote = { id: tempId, // Temporary id to track this new note title: '', content: '', lastModified: new Date().toISOString(), type: 'file', mime: 'text/markdown', etag: '' }; console.log(`[handleNewNote] BLOC-NOTES: Setting selectedNote:`, newNote); setSelectedNote(newNote); if (isMobile) { setShowNotes(false); } } }; const handleDeleteNote = async (note: Note) => { try { const response = await fetch(`/api/storage/files?id=${encodeURIComponent(note.id)}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to delete note: ${errorText}`); } // Invalidate cache for this folder if (session?.user?.id) { invalidateFolderCache(session.user.id, selectedFolder); } // Refresh the notes list using fetchNotes to ensure proper mapping // This ensures titles are correctly formatted for all folders await fetchNotes(true); // If the deleted note was selected, clear the selection if (selectedNote?.id === note.id) { setSelectedNote(null); } } catch (error) { console.error('Error deleting note:', error); throw error; // Re-throw pour permettre la gestion d'erreur en amont } }; const handleContactSave = async (contact: Contact) => { if (!session?.user?.id) return; try { setIsLoading(true); // Use S3 path structure: user-{userId}/contacts/{filename}.vcf const vcfFile = 'Allemanique.vcf'; const s3Key = `user-${session.user.id}/contacts/${vcfFile}`; let vcfContent = ''; let existingContacts: string[] = []; try { // Try to get existing contacts from the VCF file const response = await fetch(`/api/storage/files/content?path=${encodeURIComponent(s3Key)}`); if (response.ok) { const { content } = await response.json(); // Split the content into individual vCards existingContacts = content.split('BEGIN:VCARD') .filter((section: string) => section.trim()) .map((section: string) => 'BEGIN:VCARD' + section.trim()); } } catch (error) { // If the file doesn't exist, we'll create it with just the new contact console.log('No existing VCF file found, will create a new one'); } // Update or add the contact let updatedVcards: string[] = []; let contactUpdated = false; for (const vcard of existingContacts) { const parsed = parseVCard(vcard); if (parsed.uid?.[0]?.value === contact.id) { // Replace the existing contact const newVcard = [ 'BEGIN:VCARD', 'VERSION:3.0', `UID:${contact.id}`, `FN:${contact.fullName || ''}`, ...(contact.email ? [`EMAIL;TYPE=INTERNET:${contact.email}`] : []), ...(contact.phone ? [`TEL;TYPE=CELL:${contact.phone}`] : []), ...(contact.organization ? [`ORG:${contact.organization}`] : []), ...(contact.address ? [`ADR:${contact.address}`] : []), ...(contact.notes ? [`NOTE:${contact.notes}`] : []), 'END:VCARD' ].join('\n'); updatedVcards.push(newVcard); contactUpdated = true; } else { // Keep the existing contact updatedVcards.push(vcard); } } // If contact wasn't found, add it as new if (!contactUpdated) { const newVcard = [ 'BEGIN:VCARD', 'VERSION:3.0', `UID:${contact.id}`, `FN:${contact.fullName || ''}`, ...(contact.email ? [`EMAIL;TYPE=INTERNET:${contact.email}`] : []), ...(contact.phone ? [`TEL;TYPE=CELL:${contact.phone}`] : []), ...(contact.organization ? [`ORG:${contact.organization}`] : []), ...(contact.address ? [`ADR:${contact.address}`] : []), ...(contact.notes ? [`NOTE:${contact.notes}`] : []), 'END:VCARD' ].join('\n'); updatedVcards.push(newVcard); } // Join all vCards back together with proper spacing vcfContent = updatedVcards.join('\n\n'); // Save the updated VCF file using S3 storage API const saveResponse = await fetch('/api/storage/files', { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: s3Key, title: vcfFile, content: vcfContent, folder: 'contacts', mime: 'text/vcard' }), }); if (!saveResponse.ok) { const errorText = await saveResponse.text(); throw new Error(`Failed to save contact: ${errorText}`); } // Refresh the contacts list await fetchContacts(selectedFolder); // Update the selected contact if it was edited if (contactUpdated) { setSelectedContact(contact); } } catch (error) { console.error('Error saving contact:', error); throw error; // Re-throw pour permettre la gestion d'erreur en amont } finally { setIsLoading(false); } }; const handleContactDelete = async (contact: Contact) => { if (!confirm('Êtes-vous sûr de vouloir supprimer ce contact ?')) { return; } if (!session?.user?.id) { console.error('No user session available'); return; } try { setIsLoading(true); // Use S3 path structure: user-{userId}/contacts/{filename}.vcf const vcfFile = contact.group ? `${contact.group}.vcf` : 'Allemanique.vcf'; const s3Key = `user-${session.user.id}/contacts/${vcfFile}`; // Get existing contacts from the VCF file const response = await fetch(`/api/storage/files/content?path=${encodeURIComponent(s3Key)}`); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to fetch contacts: ${errorText}`); } const { content } = await response.json(); // Split the content into individual vCards and filter out the one to delete const vcards = content.split('BEGIN:VCARD').filter((section: string) => section.trim()); const updatedVcards = vcards.filter((section: string) => { const vcard = parseVCard('BEGIN:VCARD' + section); return vcard.uid?.[0]?.value !== contact.id; }); // Join the remaining vCards back together const vcfContent = updatedVcards.map((section: string) => 'BEGIN:VCARD' + section).join('\n'); // Save the updated VCF file using S3 storage API const saveResponse = await fetch('/api/storage/files', { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: s3Key, title: vcfFile, content: vcfContent, folder: 'contacts', mime: 'text/vcard' }), }); if (!saveResponse.ok) { const errorText = await saveResponse.text(); throw new Error(`Failed to delete contact: ${errorText}`); } // Clear selected contact and refresh list setSelectedContact(null); await fetchContacts(selectedFolder); } catch (error) { console.error('Error deleting contact:', error); throw error; // Re-throw pour permettre la gestion d'erreur en amont } finally { setIsLoading(false); } }; const generateVCardContent = (contact: Contact): string => { const vcard = { version: '3.0', uid: contact.id, fn: contact.fullName, email: contact.email ? [{ value: contact.email, type: 'INTERNET' }] : undefined, tel: contact.phone ? [{ value: contact.phone, type: 'CELL' }] : undefined, org: contact.organization ? [{ value: contact.organization }] : undefined, adr: contact.address ? [{ value: contact.address }] : undefined, note: contact.notes ? [{ value: contact.notes }] : undefined, categories: contact.group ? [{ value: contact.group }] : undefined }; return formatVCard(vcard); }; if (isLoading) { return (
); } return (
{/* Navigation Panel */} {showNav && ( <>
setIsDraggingNav(true)} onDragEnd={() => setIsDraggingNav(false)} onDrag={handleNavResize} /> )} {/* Notes/Contacts/Missions Panel */} {showNotes && ( <>
{selectedFolder === 'Contacts' ? ( <> {console.log(`[Render] Rendering ContactsView with ${contacts.length} contacts`, contacts)} ) : selectedFolder === 'Missions' ? ( ) : ( { // Force refresh by invalidating cache and fetching fresh data if (session?.user?.id) { invalidateFolderCache(session.user.id, selectedFolder); } fetchNotes(true); }} /> )}
{/* Notes Resizer */} setIsDraggingNotes(true)} onDragEnd={() => setIsDraggingNotes(false)} onDrag={handleNotesResize} /> )} {/* Editor Panel */}
{selectedFolder === 'Contacts' || selectedFolder.endsWith('.vcf') ? ( ) : selectedFolder === 'Missions' ? ( selectedMission && session?.user?.id ? ( selectedMissionFile ? ( { await handleMissionFileSave(note.content); }} currentFolder="Missions" /> ) : ( ) ) : (

Sélectionnez une mission

) ) : ( { // Refresh the notes list fetchNotes(); }} /> )}
{/* Mobile Navigation Toggle */} {isMobile && ( )}
); }