diff --git a/memento-note/app/layout.tsx b/memento-note/app/layout.tsx index 531d560..47300fb 100644 --- a/memento-note/app/layout.tsx +++ b/memento-note/app/layout.tsx @@ -39,7 +39,7 @@ const jetbrainsMono = JetBrains_Mono({ export const metadata: Metadata = { title: "Memento - Your Digital Notepad", description: "A beautiful note-taking app built with Next.js 16", - manifest: "/manifest.json", + manifest: "/manifest.webmanifest", icons: { icon: "/icons/icon-512.svg", apple: "/icons/icon-512.svg", diff --git a/memento-note/app/manifest.ts b/memento-note/app/manifest.ts new file mode 100644 index 0000000..c66160b --- /dev/null +++ b/memento-note/app/manifest.ts @@ -0,0 +1,27 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Memento Notes", + short_name: "Memento", + description: "A smart, local-first note taking app with AI capabilities.", + start_url: "/", + display: "standalone", + background_color: "#F2F0E9", + theme_color: "#1C1C1C", + icons: [ + { + src: "/icons/icon-192.svg", + sizes: "192x192", + type: "image/svg+xml", + purpose: "any maskable", + }, + { + src: "/icons/icon-512.svg", + sizes: "512x512", + type: "image/svg+xml", + purpose: "any maskable", + }, + ], + }; +} diff --git a/memento-note/components/contextual-ai-chat.tsx.bak b/memento-note/components/contextual-ai-chat.tsx.bak deleted file mode 100644 index d174b02..0000000 --- a/memento-note/components/contextual-ai-chat.tsx.bak +++ /dev/null @@ -1,1049 +0,0 @@ -'use client' - -import { useRef, useEffect, useState } from 'react' -import { useChat } from '@ai-sdk/react' -import { DefaultChatTransport } from 'ai' -import type { UIMessage } from 'ai' -import { cn } from '@/lib/utils' -import { Button } from '@/components/ui/button' -import { - X, Bot, Sparkles, Send, Loader2, Square, - Briefcase, Palette, GraduationCap, Coffee, - Lightbulb, Minimize2, AlignLeft, Wand2, - Globe, BookOpen, FileText, RotateCcw, Check, - Maximize2, ImageIcon, Link2, Download, ArrowDownToLine, - GitMerge, PlusCircle, Eye, Code, Languages, - Presentation, PenTool, ExternalLink, ImagePlus, - ChevronRight, MessageSquare, History, Scissors, Zap, Layout, ArrowRightLeft, -} from 'lucide-react' -import { motion, AnimatePresence } from 'motion/react' -import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image' -import { useLanguage } from '@/lib/i18n' -import { MarkdownContent } from '@/components/markdown-content' -import { toast } from 'sonner' - -// ── Custom Toast Helper ────────────────────────────────────────────────────── -const mToast = { - success: (msg: string, options?: any) => toast.success(msg, { - ...options, - className: 'memento-toast memento-toast-success', - }), - error: (msg: string, options?: any) => toast.error(msg, { - ...options, - className: 'memento-toast memento-toast-error', - }), - loading: (msg: string, options?: any) => toast.loading(msg, { - ...options, - className: 'memento-toast memento-toast-info', - }), -} -import { useWebSearchAvailable } from '@/hooks/use-web-search-available' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { getNotebookIcon } from '@/lib/notebook-icon' -import { scrapePageText } from '@/app/actions/scrape' - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function getMessageContent(msg: UIMessage): string { - if (typeof (msg as any).content === 'string') return (msg as any).content - if (msg.parts && Array.isArray(msg.parts)) { - return msg.parts - .filter((p: any) => p.type === 'text') - .map((p: any) => p.text) - .join('') - } - return '' -} - -// ── Constants ───────────────────────────────────────────────────────────────── - -const TONES = [ - { id: 'professional', label: 'PRO', full: 'Professional', icon: Briefcase }, - { id: 'creative', label: 'CRE', full: 'Creative', icon: Palette }, - { id: 'academic', label: 'ACA', full: 'Academic', icon: GraduationCap }, - { id: 'casual', label: 'CAS', full: 'Casual', icon: Coffee }, -] - -interface ActionDef { - id: string - icon: any - apiPath: string - body: (content: string, images?: string[], lang?: string, format?: string) => any - resultKey: string - i18nKey: string - isImageAction?: boolean -} - -const ACTION_IDS = [ - { id: 'clarify', icon: Lightbulb, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'clarify', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.clarify' }, - { id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'shorten', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' }, - { id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'improve', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' }, - { id: 'translate', icon: Languages, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string, format?: string) => ({ text: content, option: 'translate', language: lang || 'fr', format: format || 'markdown' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.translate' }, - { id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string, _images?: string[], _lang?: string, _format?: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' }, - { id: 'toRichText', icon: Wand2, apiPath: '/api/ai/convert-markdown', body: (content: string, _images?: string[], _lang?: string, _format?: string) => ({ content }), resultKey: 'html', i18nKey: 'ai.action.toRichText' }, - { id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string, _format?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true }, -] - -// ── Types ───────────────────────────────────────────────────────────────────── - -interface GenerateResult { - type: 'slides' | 'diagram' - canvasId?: string - noteId?: string -} - -interface ContextualAIChatProps { - onClose: () => void - noteTitle?: string - noteContent?: string - noteImages?: string[] - noteId?: string - /** Called when an action result should be injected into the note */ - onApplyToNote?: (newContent: string) => void - /** Called when the user wants to undo the last injected action */ - onUndoLastAction?: () => void - /** Whether the last action has been applied (so we can show undo) */ - lastActionApplied?: boolean - /** Notebooks available for scope selection */ - notebooks?: Array<{ id: string; name: string }> - /** Extra classes forwarded to the aside root element */ - className?: string - /** How to embed generated diagram images (markdown vs rich text HTML) */ - diagramInsertFormat?: 'markdown' | 'html' - /** Called to trigger AI title generation for the note */ - onGenerateTitle?: () => void -} - -// ── Component ───────────────────────────────────────────────────────────────── - -export function ContextualAIChat({ - onClose, - noteTitle, - noteContent, - noteImages, - noteId, - onApplyToNote, - onUndoLastAction, - lastActionApplied = false, - notebooks = [], - className, - diagramInsertFormat = 'markdown', - onGenerateTitle, -}: ContextualAIChatProps) { - const { t, language } = useLanguage() - const webSearchAvailable = useWebSearchAvailable() - - const [activeTab, setActiveTab] = useState<'chat' | 'actions' | 'resource'>('chat') - const [selectedTone, setSelectedTone] = useState('professional') - const [input, setInput] = useState('') - const [chatScope, setChatScope] = useState<'note' | 'all' | string>('note') - const [webSearch, setWebSearch] = useState(false) - const [expanded, setExpanded] = useState(false) - - // Action state - const [actionLoading, setActionLoading] = useState(null) - const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null) - const [showLangPicker, setShowLangPicker] = useState(false) - const [translateTarget, setTranslateTarget] = useState('') - - // Generate slides / diagram state - const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null) - const [generateResult, setGenerateResult] = useState(null) - const [customLangInput, setCustomLangInput] = useState('') - // Generation options - const [slideTheme, setSlideTheme] = useState('architectural_mono') - const [slideStyle, setSlideStyle] = useState('professional') - const [diagramType, setDiagramType] = useState('logic_flow') - const [diagramStyle, setDiagramStyle] = useState('polished') - const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false) - - // Resource tab state - const [resourceUrl, setResourceUrl] = useState('') - const [resourceText, setResourceText] = useState('') - const [resourceScraping, setResourceScraping] = useState(false) - const [resourceMode, setResourceMode] = useState<'replace' | 'complete' | 'merge'>('complete') - const [resourcePreview, setResourcePreview] = useState<{ text: string; source: string } | null>(null) - const [resourceEnriching, setResourceEnriching] = useState(false) - const [resourcePreviewFormat, setResourcePreviewFormat] = useState<'rendered' | 'markdown'>('rendered') - // hoveredMsgId: which chat message shows inject actions - const [hoveredMsgId, setHoveredMsgId] = useState(null) - - const messagesEndRef = useRef(null) - - const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current - - const buildChatBody = () => { - const body: Record = { - language, - webSearch, - format: diagramInsertFormat || 'markdown' - } - if (chatScope === 'note') { - body.noteContext = { - title: noteTitle || '', - content: noteContent || '', - tone: selectedTone, - images: noteImages || [], - } - } else if (chatScope !== 'all') { - // scope is a notebook ID - body.notebookId = chatScope - } - return body - } - - const { messages, sendMessage, status, stop } = useChat({ transport }) - - const lastMsg = messages[messages.length - 1] - const lastMsgHasContent = lastMsg?.role === 'assistant' && !!getMessageContent(lastMsg) - const isLoading = (status === 'submitted' || status === 'streaming') && !lastMsgHasContent - - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, resourcePreview]) - - useEffect(() => { - window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true })) - return () => { - window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false })) - } - }, []) - - // ── Chat send ─────────────────────────────────────────────────────────────── - const handleSend = async () => { - const text = input.trim() - if (!text || isLoading) return - setInput('') - await sendMessage({ text }, { body: buildChatBody() }) - } - - // ── Action execution ──────────────────────────────────────────────────────── - const handleAction = async (action: ActionDef, targetLang?: string) => { - if (action.isImageAction) { - if (!noteImages || noteImages.length === 0) { - mToast.error(t('ai.noImagesError') || 'Aucune image dans cette note') - return - } - setActionLoading(action.id) - setActionPreview(null) - try { - const res = await fetch(action.apiPath, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(action.body('', noteImages, language)), - }) - const data = await res.json() - if (!res.ok) throw new Error(data.error || t('ai.genericError')) - const descs = data.descriptions || [] - let resultText = descs.map((d: any) => - noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description - ).join('\n\n') - if (data.combinedSummary) { - resultText += `\n\n---\n**Résumé:** ${data.combinedSummary}` - } - setActionPreview({ label: t(action.i18nKey), text: resultText }) - } catch (e: any) { - mToast.error(e.message || t('ai.actionError')) - } finally { - setActionLoading(null) - } - return - } - - const wc = (noteContent || '').split(/\s+/).filter(Boolean).length - if (!noteContent || wc < 5) { - mToast.error(t('ai.minWordsError')) - return - } - setActionLoading(action.id) - setActionPreview(null) - try { - const format = diagramInsertFormat || 'markdown' - const res = await fetch(action.apiPath, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(action.body(noteContent, undefined, targetLang || language, format)), - }) - const data = await res.json() - if (!res.ok) throw new Error(data.error || t('ai.genericError')) - const result = data[action.resultKey] || '' - setActionPreview({ label: t(action.i18nKey), text: result }) - } catch (e: any) { - mToast.error(e.message || t('ai.actionError')) - } finally { - setActionLoading(null) - } - } - - const handleApplyPreview = () => { - if (!actionPreview || !onApplyToNote) return - onApplyToNote(actionPreview.text) - setActionPreview(null) - mToast.success(t('ai.appliedToNote')) - } - - const handleDiscardPreview = () => setActionPreview(null) - - // ── Generate slides / diagram ──────────────────────────────────────────────── - - const generatePollRef = useRef | null>(null) - - const handleGenerate = async (type: 'slides' | 'diagram') => { - if (!noteId) { - mToast.error(t('ai.generate.noNoteId') || 'Note non sauvegardée') - return - } - setGenerateLoading(type) - setGenerateResult(null) - - const toastId = mToast.loading( - type === 'slides' ? '⏳ Génération de la présentation...' : '⏳ Génération du diagramme...', - { duration: Infinity } - ) - - try { - const res = await fetch('/api/agents/run-for-note', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - noteId, - type: type === 'slides' ? 'slide-generator' : 'excalidraw-generator', - theme: type === 'slides' ? slideTheme : diagramType, - style: type === 'slides' ? slideStyle : diagramStyle, - language: language === 'fr' ? 'French' : 'English', - }), - }) - const data = await res.json() - if (!res.ok || !data.success) { - mToast.error(data.error || 'Erreur', { id: toastId }) - setGenerateLoading(null) - return - } - - const { agentId } = data as { agentId: string } - - if (generatePollRef.current) clearInterval(generatePollRef.current) - generatePollRef.current = setInterval(async () => { - try { - const pollRes = await fetch(`/api/agents/run-for-note?agentId=${agentId}`) - const poll = await pollRes.json() - - if (poll.status === 'success') { - clearInterval(generatePollRef.current!) - generatePollRef.current = null - setGenerateLoading(null) - setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId }) - mToast.success('Prêt !', { id: toastId }) - } else if (poll.status === 'failure') { - clearInterval(generatePollRef.current!) - generatePollRef.current = null - setGenerateLoading(null) - mToast.error(poll.error || 'Erreur', { id: toastId }) - } - } catch { } - }, 3000) - } catch { - mToast.error('Erreur', { id: toastId }) - setGenerateLoading(null) - } - } - - const buildDiagramImageSnippet = (imageUrl: string, alt: string) => { - if (diagramInsertFormat === 'html') { - return `\n

${alt}

\n` - } - return `\n\n![${alt}](${imageUrl})\n\n` - } - - const handleEmbedDiagramInNote = async (canvasId: string) => { - if (!onApplyToNote) return - setDiagramEmbedLoading(true) - try { - const res = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`) - const data = await res.json() - if (!res.ok || !data.canvas?.data) throw new Error() - const blob = await exportExcalidrawSceneToPngBlob(data.canvas.data) - if (!blob) throw new Error() - const fd = new FormData() - fd.append('file', blob, `diagram-${canvasId.slice(-8)}.png`) - const up = await fetch('/api/upload', { method: 'POST', body: fd }) - const upJson = await up.json() - if (!up.ok || !upJson.url) throw new Error() - const alt = t('ai.generate.diagramImageAlt') - onApplyToNote(`${noteContent ?? ''}${buildDiagramImageSnippet(upJson.url as string, alt)}`) - mToast.success(t('ai.generate.insertedInNote')) - } catch (e) { - mToast.error(t('ai.generate.insertExportError')) - } finally { - setDiagramEmbedLoading(false) - } - } - - // ── Resource tab handlers ──────────────────────────────────────────────────── - - const handleScrapeUrl = async () => { - if (!resourceUrl.trim()) return - setResourceScraping(true) - try { - const result = await scrapePageText(resourceUrl.trim()) - if (!result) { mToast.error(t('ai.resource.failedToLoadUrl')); return } - setResourceText(result.text) - mToast.success(t('ai.resource.pageLoaded', { title: result.title.slice(0, 40) })) - } catch { - mToast.error(t('ai.resource.pageLoadError')) - } finally { - setResourceScraping(false) - } - } - - const handleResourcePreview = async () => { - if (!resourceText.trim()) { mToast.error(t('ai.resource.pasteOrUrlFirst')); return } - if (resourceMode === 'replace') { - setResourcePreview({ text: resourceText, source: 'paste' }) - return - } - - setResourceEnriching(true) - try { - const res = await fetch('/api/ai/enrich-from-resource', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - existingContent: noteContent || '', - resourceText: resourceText.trim(), - mode: resourceMode, - language, - format: diagramInsertFormat || 'markdown', - }), - }) - const data = await res.json() - if (!res.ok) throw new Error(data.error || t('ai.resource.enrichError')) - setResourcePreview({ text: data.enrichedContent, source: resourceMode }) - } catch (e: any) { - mToast.error(e.message || t('ai.resource.enrichError')) - } finally { - setResourceEnriching(false) - } - } - - const handleApplyResourcePreview = () => { - if (!resourcePreview || !onApplyToNote) return - onApplyToNote(resourcePreview.text) - setResourcePreview(null) - setResourceText('') - setResourceUrl('') - mToast.success(t('ai.resource.contentApplied')) - } - - const handleInjectFromChat = async (msgText: string, mode: 'replace' | 'complete' | 'merge') => { - setResourceText(msgText) - setResourceMode(mode) - - if (mode === 'replace') { - setResourcePreview({ text: msgText, source: 'chat' }) - return - } - - setResourceEnriching(true) - try { - const res = await fetch('/api/ai/enrich-from-resource', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - existingContent: noteContent || '', - resourceText: msgText, - mode, - language, - format: diagramInsertFormat || 'markdown', - }), - }) - const data = await res.json() - if (!res.ok) throw new Error(data.error || 'Erreur IA') - setResourcePreview({ text: data.enrichedContent, source: mode }) - } catch (e: any) { - mToast.error(e.message || t('ai.resource.enrichErrorShort')) - } finally { - setResourceEnriching(false) - } - } - - return ( - <> - {expanded && ( -
setExpanded(false)} - /> - )} -