chore: clean up repo for public release
- Remove BMAD framework, IDE configs, dev screenshots, test files, internal docs, and backup files - Rename keep-notes/ to memento-note/ - Update all references from keep-notes to memento-note - Add Apache 2.0 license with Commons Clause (non-commercial restriction) - Add clean .gitignore and .env.docker.example
This commit is contained in:
62
memento-note/hooks/use-auto-label-suggestion.ts
Normal file
62
memento-note/hooks/use-auto-label-suggestion.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
/**
|
||||
* Hook to check if auto label suggestions should be shown for the current notebook
|
||||
* Triggers when notebook has 15+ notes (IA4)
|
||||
*/
|
||||
export function useAutoLabelSuggestion() {
|
||||
const { data: session } = useSession()
|
||||
const searchParams = useSearchParams()
|
||||
const [shouldSuggest, setShouldSuggest] = useState(false)
|
||||
const [notebookId, setNotebookId] = useState<string | null>(searchParams.get('notebook'))
|
||||
const [hasChecked, setHasChecked] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const currentNotebookId = searchParams.get('notebook')
|
||||
|
||||
// Reset when notebook changes
|
||||
if (currentNotebookId !== notebookId) {
|
||||
setNotebookId(currentNotebookId)
|
||||
setHasChecked(false)
|
||||
setShouldSuggest(false)
|
||||
|
||||
// Check if we should suggest labels for this notebook
|
||||
if (currentNotebookId && session?.user?.id) {
|
||||
checkNotebookForSuggestions(currentNotebookId)
|
||||
}
|
||||
}
|
||||
}, [searchParams, notebookId, session])
|
||||
|
||||
const checkNotebookForSuggestions = async (nbId: string) => {
|
||||
try {
|
||||
// Check if notebook has 15+ notes
|
||||
const response = await fetch('/api/ai/auto-labels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ notebookId: nbId }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Show suggestions if available
|
||||
if (data.success && data.data) {
|
||||
setShouldSuggest(true)
|
||||
}
|
||||
|
||||
setHasChecked(true)
|
||||
} catch (error) {
|
||||
console.error('Failed to check for label suggestions:', error)
|
||||
setHasChecked(true)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shouldSuggest,
|
||||
notebookId,
|
||||
hasChecked,
|
||||
dismiss: () => setShouldSuggest(false),
|
||||
}
|
||||
}
|
||||
96
memento-note/hooks/use-auto-tagging.ts
Normal file
96
memento-note/hooks/use-auto-tagging.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useDebounce } from './use-debounce';
|
||||
import { TagSuggestion } from '@/lib/ai/types';
|
||||
|
||||
interface UseAutoTaggingProps {
|
||||
content: string;
|
||||
notebookId?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoTaggingProps) {
|
||||
const { language } = useLanguage();
|
||||
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Debounce le contenu de 1.5s
|
||||
const debouncedContent = useDebounce(content, 1500);
|
||||
|
||||
// Track previous notebookId to detect when note is moved to a notebook
|
||||
const previousNotebookId = useRef<string | null | undefined>(notebookId);
|
||||
|
||||
const analyzeContent = async (contentToAnalyze: string) => {
|
||||
// CRITICAL: Don't suggest labels in "Notes générales" (notebookId is null)
|
||||
// Labels should ONLY appear within notebooks, not in the general notes section
|
||||
if (!notebookId) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!contentToAnalyze || contentToAnalyze.length < 10) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/tags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: contentToAnalyze,
|
||||
notebookId: notebookId || undefined,
|
||||
language: language || document.documentElement.lang || 'en',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de l\'analyse');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSuggestions(data.tags || []);
|
||||
} catch (err) {
|
||||
setError('Impossible de générer des suggestions');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger on content change
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
analyzeContent(debouncedContent);
|
||||
}, [debouncedContent, enabled]);
|
||||
|
||||
// CRITICAL: Also trigger when notebookId changes from null/undefined to a value (note moved to notebook)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const prev = previousNotebookId.current;
|
||||
previousNotebookId.current = notebookId;
|
||||
|
||||
// Detect when note is moved FROM "Notes générales" (null) TO a notebook
|
||||
const wasMovedToNotebook = (prev === null || prev === undefined) && notebookId;
|
||||
|
||||
if (wasMovedToNotebook && content && content.length >= 10) {
|
||||
// Use current content immediately (no debounce) when moving to notebook
|
||||
analyzeContent(content);
|
||||
}
|
||||
}, [notebookId, content, enabled]);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
isAnalyzing,
|
||||
error,
|
||||
clearSuggestions: () => setSuggestions([]),
|
||||
};
|
||||
}
|
||||
37
memento-note/hooks/use-card-size-mode.ts
Normal file
37
memento-note/hooks/use-card-size-mode.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
type CardSizeMode = 'variable' | 'uniform'
|
||||
|
||||
export function useCardSizeMode(): CardSizeMode {
|
||||
const [mode, setMode] = useState<CardSizeMode>('variable')
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage first (for immediate UI response)
|
||||
const stored = localStorage.getItem('card-size-mode') as CardSizeMode | null
|
||||
if (stored && (stored === 'variable' || stored === 'uniform')) {
|
||||
setMode(stored)
|
||||
}
|
||||
|
||||
// Listen for storage changes (when user changes setting in another tab)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'card-size-mode') {
|
||||
const newMode = e.newValue as CardSizeMode | null
|
||||
if (newMode && (newMode === 'variable' || newMode === 'uniform')) {
|
||||
setMode(newMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
return () => window.removeEventListener('storage', handleStorageChange)
|
||||
}, [])
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
export function useIsUniformSize(): boolean {
|
||||
const mode = useCardSizeMode()
|
||||
return mode === 'uniform'
|
||||
}
|
||||
44
memento-note/hooks/use-connections-compare.ts
Normal file
44
memento-note/hooks/use-connections-compare.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getNoteById } from '@/app/actions/notes'
|
||||
import { Note } from '@/lib/types'
|
||||
|
||||
export function useConnectionsCompare(noteIds: string[] | null) {
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Early return if no noteIds or empty array
|
||||
if (!noteIds || noteIds.length === 0) {
|
||||
setNotes([])
|
||||
return
|
||||
}
|
||||
|
||||
const fetchNotes = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const fetchedNotes = await Promise.all(
|
||||
noteIds.map(id => getNoteById(id))
|
||||
)
|
||||
|
||||
// Filter out null/undefined notes
|
||||
const validNotes = fetchedNotes.filter((note): note is Note => note !== null && note !== undefined)
|
||||
|
||||
setNotes(validNotes)
|
||||
} catch (err) {
|
||||
console.error('[useConnectionsCompare] Failed to fetch notes:', err)
|
||||
setError('Failed to load notes')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchNotes()
|
||||
}, [noteIds])
|
||||
|
||||
return { notes, isLoading, error }
|
||||
}
|
||||
17
memento-note/hooks/use-debounce.ts
Normal file
17
memento-note/hooks/use-debounce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
36
memento-note/hooks/use-note-drag.ts
Normal file
36
memento-note/hooks/use-note-drag.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export type DragState = 'idle' | 'dragging' | 'drag-over'
|
||||
|
||||
export function useNoteDrag() {
|
||||
const [draggedNoteId, setDraggedNoteId] = useState<string | null>(null)
|
||||
const [dragOverNotebookId, setDragOverNotebookId] = useState<string | null>(null)
|
||||
|
||||
const startDrag = useCallback((noteId: string) => {
|
||||
setDraggedNoteId(noteId)
|
||||
}, [])
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDraggedNoteId(null)
|
||||
setDragOverNotebookId(null)
|
||||
}, [])
|
||||
|
||||
const dragOver = useCallback((notebookId: string | null) => {
|
||||
setDragOverNotebookId(notebookId)
|
||||
}, [])
|
||||
|
||||
const isDragging = draggedNoteId !== null
|
||||
const isDragOver = dragOverNotebookId !== null
|
||||
|
||||
return {
|
||||
draggedNoteId,
|
||||
dragOverNotebookId,
|
||||
startDrag,
|
||||
endDrag,
|
||||
dragOver,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
}
|
||||
}
|
||||
53
memento-note/hooks/use-reminder-check.ts
Normal file
53
memento-note/hooks/use-reminder-check.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Note } from '@/lib/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const STORAGE_KEY = 'memento-notified-reminders';
|
||||
|
||||
function getNotifiedFromStorage(): Set<string> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function persistNotified(ids: Set<string>) {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
|
||||
} catch { /* quota exceeded — non-critical */ }
|
||||
}
|
||||
|
||||
export function useReminderCheck(notes: Note[]) {
|
||||
const notifiedRef = useRef<Set<string>>(getNotifiedFromStorage());
|
||||
|
||||
useEffect(() => {
|
||||
const checkReminders = () => {
|
||||
const now = new Date();
|
||||
const newIds: string[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
if (!note.reminder || note.isReminderDone) continue;
|
||||
if (notifiedRef.current.has(note.id)) continue;
|
||||
|
||||
const reminderDate = new Date(note.reminder);
|
||||
if (reminderDate <= now) {
|
||||
newIds.push(note.id);
|
||||
toast.info(`🔔 ${note.title || 'Untitled Note'}`, { id: `reminder-${note.id}` });
|
||||
}
|
||||
}
|
||||
|
||||
if (newIds.length > 0) {
|
||||
for (const id of newIds) notifiedRef.current.add(id);
|
||||
persistNotified(notifiedRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
checkReminders();
|
||||
const interval = setInterval(checkReminders, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [notes]);
|
||||
}
|
||||
33
memento-note/hooks/use-resize-observer.ts
Normal file
33
memento-note/hooks/use-resize-observer.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function useResizeObserver(callback: (entry: ResizeObserverEntry) => void) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const frameId = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
// Cancel previous frame to avoid stacking updates
|
||||
if (frameId.current) cancelAnimationFrame(frameId.current);
|
||||
|
||||
frameId.current = requestAnimationFrame(() => {
|
||||
for (const entry of entries) {
|
||||
callback(entry);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frameId.current) cancelAnimationFrame(frameId.current);
|
||||
};
|
||||
}, [callback]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
74
memento-note/hooks/use-title-suggestions.ts
Normal file
74
memento-note/hooks/use-title-suggestions.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useDebounce } from './use-debounce'
|
||||
|
||||
export interface TitleSuggestion {
|
||||
title: string
|
||||
confidence: number
|
||||
reasoning?: string
|
||||
}
|
||||
|
||||
interface UseTitleSuggestionsProps {
|
||||
content: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function useTitleSuggestions({ content, enabled = true }: UseTitleSuggestionsProps) {
|
||||
const [suggestions, setSuggestions] = useState<TitleSuggestion[]>([])
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Debounce le contenu de 2s pour éviter trop d'appels
|
||||
const debouncedContent = useDebounce(content, 2000)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !debouncedContent) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
const wordCount = debouncedContent.split(/\s+/).length
|
||||
|
||||
// Il faut au moins 10 mots
|
||||
if (wordCount < 10) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const generateTitles = async () => {
|
||||
setIsAnalyzing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/title-suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: debouncedContent }),
|
||||
})
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Erreur lors de la génération des titres')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSuggestions(data.suggestions || [])
|
||||
} catch (err) {
|
||||
console.error('❌ Title suggestions error:', err)
|
||||
setError('Impossible de générer des suggestions de titres')
|
||||
} finally {
|
||||
setIsAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
generateTitles()
|
||||
}, [debouncedContent, enabled])
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
isAnalyzing,
|
||||
error,
|
||||
clearSuggestions: () => setSuggestions([])
|
||||
}
|
||||
}
|
||||
116
memento-note/hooks/useUndoRedo.ts
Normal file
116
memento-note/hooks/useUndoRedo.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { deepEqual } from '@/lib/utils'
|
||||
|
||||
export interface UndoRedoState<T> {
|
||||
past: T[]
|
||||
present: T
|
||||
future: T[]
|
||||
}
|
||||
|
||||
interface UseUndoRedoReturn<T> {
|
||||
state: T
|
||||
setState: (newState: T | ((prev: T) => T)) => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const MAX_HISTORY_SIZE = 50
|
||||
|
||||
export function useUndoRedo<T>(initialState: T): UseUndoRedoReturn<T> {
|
||||
const [history, setHistory] = useState<UndoRedoState<T>>({
|
||||
past: [],
|
||||
present: initialState,
|
||||
future: [],
|
||||
})
|
||||
|
||||
// Track if we're in an undo/redo operation to prevent adding to history
|
||||
const isUndoRedoAction = useRef(false)
|
||||
|
||||
const setState = useCallback((newState: T | ((prev: T) => T)) => {
|
||||
// Skip if this is an undo/redo action
|
||||
if (isUndoRedoAction.current) {
|
||||
isUndoRedoAction.current = false
|
||||
return
|
||||
}
|
||||
|
||||
setHistory((currentHistory) => {
|
||||
const resolvedNewState =
|
||||
typeof newState === 'function'
|
||||
? (newState as (prev: T) => T)(currentHistory.present)
|
||||
: newState
|
||||
|
||||
// Don't add to history if state hasn't changed
|
||||
if (deepEqual(resolvedNewState, currentHistory.present)) {
|
||||
return currentHistory
|
||||
}
|
||||
|
||||
const newPast = [...currentHistory.past, currentHistory.present]
|
||||
|
||||
// Limit history size
|
||||
if (newPast.length > MAX_HISTORY_SIZE) {
|
||||
newPast.shift()
|
||||
}
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
present: resolvedNewState,
|
||||
future: [], // Clear future on new action
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const undo = useCallback(() => {
|
||||
setHistory((currentHistory) => {
|
||||
if (currentHistory.past.length === 0) return currentHistory
|
||||
|
||||
const previous = currentHistory.past[currentHistory.past.length - 1]
|
||||
const newPast = currentHistory.past.slice(0, currentHistory.past.length - 1)
|
||||
|
||||
isUndoRedoAction.current = true
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
present: previous,
|
||||
future: [currentHistory.present, ...currentHistory.future],
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
setHistory((currentHistory) => {
|
||||
if (currentHistory.future.length === 0) return currentHistory
|
||||
|
||||
const next = currentHistory.future[0]
|
||||
const newFuture = currentHistory.future.slice(1)
|
||||
|
||||
isUndoRedoAction.current = true
|
||||
|
||||
return {
|
||||
past: [...currentHistory.past, currentHistory.present],
|
||||
present: next,
|
||||
future: newFuture,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setHistory({
|
||||
past: [],
|
||||
present: initialState,
|
||||
future: [],
|
||||
})
|
||||
}, [initialState])
|
||||
|
||||
return {
|
||||
state: history.present,
|
||||
setState,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: history.past.length > 0,
|
||||
canRedo: history.future.length > 0,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user