353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import { Image, FileText, Link, List, Plus } from 'lucide-react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useSession } from 'next-auth/react';
|
|
import { noteContentCache } from '@/lib/cache-utils';
|
|
import { HealthForm } from './health-form';
|
|
|
|
interface Note {
|
|
id: string;
|
|
title: string;
|
|
content: string;
|
|
lastModified: string;
|
|
type: string;
|
|
mime: string;
|
|
etag: string;
|
|
}
|
|
|
|
interface EditorProps {
|
|
note?: Note | null;
|
|
onSave?: (note: Note) => void;
|
|
currentFolder?: string;
|
|
onRefresh?: () => void;
|
|
}
|
|
|
|
export const Editor: React.FC<EditorProps> = ({ note, onSave, currentFolder = 'Notes', onRefresh }) => {
|
|
const [title, setTitle] = useState<string>(note?.title || '');
|
|
const [content, setContent] = useState<string>(note?.content || '');
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const saveTimeout = useRef<NodeJS.Timeout>();
|
|
const router = useRouter();
|
|
const { data: session, status } = useSession();
|
|
|
|
useEffect(() => {
|
|
// Redirect to login if not authenticated
|
|
if (status === 'unauthenticated') {
|
|
router.push('/signin');
|
|
}
|
|
}, [status, router]);
|
|
|
|
useEffect(() => {
|
|
const fetchNoteContent = async () => {
|
|
if (note?.id) {
|
|
// Skip fetching if id is temporary (starts with 'temp-')
|
|
// Temporary ids are used for new notes that haven't been saved yet
|
|
if (note.id.startsWith('temp-')) {
|
|
console.log(`Skipping fetch for temporary note id: ${note.id}`);
|
|
setIsLoading(false);
|
|
setContent(note.content || '');
|
|
return;
|
|
}
|
|
|
|
// If content is already provided (e.g., for mission files), use it directly
|
|
if (note.content !== undefined && note.content !== '') {
|
|
setContent(note.content);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
// Check cache first
|
|
const cachedContent = noteContentCache.get<string>(note.id);
|
|
if (cachedContent) {
|
|
console.log(`Using cached content for note ${note.title}`);
|
|
setContent(cachedContent);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
// For mission files, don't try to fetch via storage API
|
|
if (note.id.startsWith('missions/')) {
|
|
console.warn('Mission file content should be provided directly, not fetched');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
// If cache miss, fetch from API
|
|
try {
|
|
const response = await fetch(`/api/storage/files/content?path=${encodeURIComponent(note.id)}`);
|
|
if (response.status === 401) {
|
|
console.error('Authentication error, redirecting to login');
|
|
router.push('/signin');
|
|
return;
|
|
}
|
|
if (response.status === 404) {
|
|
// File not found - this is normal for new notes that haven't been saved yet
|
|
console.log(`Note not found (404) for id: ${note.id}, treating as new note`);
|
|
setContent('');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
if (response.status === 403) {
|
|
// Unauthorized access - don't show error, just clear content
|
|
console.warn(`Unauthorized access to note: ${note.id}`);
|
|
setContent('');
|
|
setError(null); // Don't show error for unauthorized access
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
const errorMessage = errorData.message || errorData.error || `Failed to fetch note content: ${response.status}`;
|
|
// Only show error for non-404/403 errors
|
|
if (response.status !== 404 && response.status !== 403) {
|
|
throw new Error(errorMessage);
|
|
} else {
|
|
setContent('');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
}
|
|
const data = await response.json();
|
|
setContent(data.content || '');
|
|
|
|
// Update cache
|
|
noteContentCache.set(note.id, data.content || '');
|
|
} catch (error) {
|
|
console.error('Error fetching note content:', error);
|
|
// Only show error if it's not a 404 or 403 (which we handle above)
|
|
const errorMessage = error instanceof Error ? error.message : 'Failed to load note content. Please try again later.';
|
|
// Don't show error for file not found or unauthorized - these are handled above
|
|
if (!errorMessage.includes('404') && !errorMessage.includes('403') && !errorMessage.includes('File not found') && !errorMessage.includes('Unauthorized')) {
|
|
setError(errorMessage);
|
|
} else {
|
|
setError(null);
|
|
setContent('');
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (note) {
|
|
// For Diary and Health, if it's a new note (no id), set title to today's date
|
|
if ((currentFolder === 'Diary' || currentFolder === 'Health') && !note.id) {
|
|
const today = new Date();
|
|
// Use French locale for date formatting: "16 janvier 2026"
|
|
const dateStr = today.toLocaleDateString('fr-FR', {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric'
|
|
});
|
|
setTitle(dateStr);
|
|
} else {
|
|
setTitle(note.title || '');
|
|
}
|
|
// Set content from note if provided, otherwise it will be fetched
|
|
if (note.content !== undefined) {
|
|
setContent(note.content);
|
|
}
|
|
if (note.id) {
|
|
fetchNoteContent();
|
|
} else {
|
|
if (note.content === undefined) {
|
|
setContent('');
|
|
}
|
|
}
|
|
} else {
|
|
setTitle('');
|
|
setContent('');
|
|
}
|
|
}, [note, router, currentFolder]);
|
|
|
|
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setTitle(e.target.value);
|
|
debouncedSave();
|
|
};
|
|
|
|
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
setContent(e.target.value);
|
|
debouncedSave();
|
|
};
|
|
|
|
// Handle content change from HealthForm (direct string update)
|
|
const handleHealthContentChange = (newContent: string) => {
|
|
setContent(newContent);
|
|
// For Health folder, pass the newContent directly to debouncedSave to ensure we use the latest content
|
|
// This is important because setContent is async and content state might not be updated when debouncedSave fires
|
|
debouncedSave(newContent);
|
|
};
|
|
|
|
const debouncedSave = (contentToSave?: string) => {
|
|
if (saveTimeout.current) {
|
|
clearTimeout(saveTimeout.current);
|
|
}
|
|
saveTimeout.current = setTimeout(() => {
|
|
console.log(`[Editor] debouncedSave triggered for folder: ${currentFolder}`);
|
|
// Use the provided contentToSave if available, otherwise use state content
|
|
handleSave(contentToSave);
|
|
}, 1000); // Save after 1 second of inactivity
|
|
};
|
|
|
|
const handleSave = async (contentToSave?: string) => {
|
|
// Use the provided contentToSave if available, otherwise use state content
|
|
// This ensures we use the latest content from HealthForm even if state hasn't updated yet
|
|
const contentToUse = contentToSave !== undefined ? contentToSave : content;
|
|
|
|
console.log(`[Editor] handleSave called - folder: ${currentFolder}, title: "${title}", content length: ${contentToUse?.length || 0}`);
|
|
if (!title || !contentToUse) {
|
|
console.log(`[Editor] handleSave: Skipping save - title: "${title}", content: "${contentToUse}"`);
|
|
return;
|
|
}
|
|
if (!session) {
|
|
console.error('No active session, cannot save');
|
|
setError('You must be logged in to save notes');
|
|
return;
|
|
}
|
|
|
|
// For Health folder, don't save if content is just empty JSON
|
|
if (currentFolder === 'Health') {
|
|
try {
|
|
const parsed = JSON.parse(contentToUse);
|
|
const isEmpty = Object.keys(parsed).length === 0 || (Object.keys(parsed).length === 1 && parsed.date);
|
|
if (isEmpty) {
|
|
console.log('[Editor] handleSave: Skipping save for empty Health form');
|
|
return; // Don't save empty health forms
|
|
}
|
|
} catch {
|
|
// If not valid JSON, continue with save
|
|
}
|
|
}
|
|
|
|
setIsSaving(true);
|
|
setError(null);
|
|
|
|
// Instead of saving directly, construct a Note object and pass it to onSave
|
|
// This ensures handleSaveNote in page.tsx handles all the logic consistently
|
|
try {
|
|
// Construct a Note object with current state
|
|
const noteToSave: Note = {
|
|
id: note?.id || '', // Use existing note.id if available, otherwise empty (will be determined in handleSaveNote)
|
|
title: title, // Use the title from local state
|
|
content: contentToUse, // Use the provided content or state content
|
|
lastModified: note?.lastModified || new Date().toISOString(),
|
|
type: note?.type || 'file',
|
|
mime: note?.mime || 'text/markdown',
|
|
etag: note?.etag || ''
|
|
};
|
|
|
|
console.log('[Editor] Calling onSave with note (delegating to handleSaveNote):', { ...noteToSave, content: `[${contentToUse.length} chars]` });
|
|
// Pass the note to onSave callback, which will call handleSaveNote in page.tsx
|
|
// handleSaveNote will handle the actual API call with proper duplicate detection
|
|
onSave?.(noteToSave);
|
|
// Note: onRefresh is called, but handleSaveNote already calls fetchNotes
|
|
// So we don't need to call onRefresh here to avoid double fetch
|
|
// onRefresh?.();
|
|
} catch (error) {
|
|
console.error('Error saving note:', error);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
if (!session && status !== 'loading') {
|
|
return (
|
|
<div className="flex flex-col h-full bg-carnet-bg items-center justify-center">
|
|
<div className="text-center">
|
|
<h2 className="text-xl font-semibold text-carnet-text-primary mb-2">
|
|
Authentication Required
|
|
</h2>
|
|
<p className="text-carnet-text-muted mb-4">
|
|
Please log in to access your notes
|
|
</p>
|
|
<button
|
|
onClick={() => router.push('/signin')}
|
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
|
>
|
|
Go to Login
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!note) {
|
|
return (
|
|
<div className="flex flex-col h-full bg-carnet-bg items-center justify-center">
|
|
<div className="text-center">
|
|
<FileText className="h-12 w-12 text-carnet-text-muted mx-auto mb-4" />
|
|
<h2 className="text-xl font-semibold text-carnet-text-primary mb-2">
|
|
Sélectionnez une note
|
|
</h2>
|
|
<p className="text-carnet-text-muted">
|
|
Choisissez une note existante ou créez-en une nouvelle
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-carnet-bg">
|
|
{/* Title Bar */}
|
|
<div className="p-4 border-b border-carnet-border">
|
|
<input
|
|
type="text"
|
|
value={title ?? ''}
|
|
onChange={handleTitleChange}
|
|
placeholder="Titre"
|
|
disabled={currentFolder === 'Diary' || currentFolder === 'Health'}
|
|
className={`w-full text-xl font-semibold text-carnet-text-primary placeholder-carnet-text-muted focus:outline-none bg-transparent ${
|
|
(currentFolder === 'Diary' || currentFolder === 'Health') ? 'opacity-60 cursor-not-allowed' : ''
|
|
}`}
|
|
title={currentFolder === 'Diary' || currentFolder === 'Health' ? 'Le titre est automatiquement la date du jour pour les journaux' : ''}
|
|
/>
|
|
</div>
|
|
|
|
{/* Error Display */}
|
|
{error && (
|
|
<div className="p-2 m-2 bg-red-100 border border-red-400 text-red-700 rounded">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Editor Area */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
|
|
</div>
|
|
) : currentFolder === 'Health' ? (
|
|
// Use HealthForm for Health folder
|
|
<HealthForm
|
|
content={content}
|
|
onContentChange={handleHealthContentChange}
|
|
date={note?.lastModified}
|
|
/>
|
|
) : (
|
|
<div className="p-4">
|
|
<textarea
|
|
value={content ?? ''}
|
|
onChange={handleContentChange}
|
|
placeholder="Ecrire..."
|
|
className="w-full h-full resize-none focus:outline-none bg-transparent text-carnet-text-primary placeholder-carnet-text-muted"
|
|
style={{ minHeight: '400px' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isSaving && (
|
|
<div className="absolute bottom-4 right-4 text-sm text-carnet-text-muted">
|
|
Enregistrement...
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|