19 Commits

Author SHA1 Message Date
Antigravity
574c8b3166 refactor: migrate remaining components to useRefresh hook
Replace triggerRefresh() with useRefresh() in:
- notes-tabs-view.tsx (5 calls → refreshNotes)
- label-management-dialog.tsx (3 calls → refreshLabels)
- note-inline-editor.tsx (3 calls → refreshNotes)
- note-card.tsx (7 calls → refreshNotes)
- recent-notes-section.tsx (3 calls → refreshNotes)
- notification-panel.tsx (2 calls → refreshNotes(null))
- notes-editorial-view.tsx (4 calls → refreshNotes)

NoteRefreshContext marked as @deprecated with JSDoc migration guide.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:45:50 +00:00
Antigravity
9b8df398dc refactor: sidebar removes useNoteRefreshOptional dependency
Removed useNoteRefreshOptional() and refreshKey from sidebar.
The notebook note titles useEffect now only depends on [notebooks]
instead of [notebookIdsKey, refreshKey, notebooks, t].

This means sidebar note titles only re-fetch when notebooks
change (add/delete/reorder), not on every triggerRefresh().
Individual note changes are handled by React Query cache invalidation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:37:51 +00:00
Antigravity
65568c0f07 refactor: home-client uses useRefresh() for note invalidation
Replace direct triggerRefresh() with useRefresh().refreshNotes()
in handleEditorClose. This invalidates the notes cache via
React Query and triggers the old refresh mechanism for backward compat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:34:20 +00:00
Antigravity
def683982c refactor: note-editor-context uses invalidateQueries on save/copy
Replace triggerRefresh() with targeted invalidateQueries() for:
- handleSave: invalidate note + notes list for notebookId
- handleSaveInPlace: invalidate note + notes list for notebookId
- handleMakeCopy: invalidate notes list for current notebook

Keeps triggerRefresh() for backward compat until fully migrated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:32:30 +00:00
Antigravity
91b1201112 refactor: split NoteEditor into focused components + consolidate contexts
Phase 1: NoteEditor Split (64KB → 9 focused components)
- components/note-editor/: types.ts, context, toolbar, title-block,
  content-area, metadata-section, full-page, dialog compositions
- Maintains backwards compatibility via re-export from note-editor.tsx

Phase 2: Context Consolidation (5 → 3 contexts)
- NotebooksContext absorbs LabelContext (labels CRUD)
- EditorUIContext merges HomeViewContext + NotebookDragContext
- Removed: LabelContext, home-view-context, notebook-drag-context

Phase 3: React Query Infrastructure
- Added QueryProvider with @tanstack/react-query
- lib/query-keys.ts: centralized query key definitions
- lib/query-hooks.ts: useNotes, useNotebooksQuery, useLabelsQuery
- lib/use-refresh.ts: hybrid invalidateQueries + triggerRefresh helper
- NotebooksContext: invalidateQueries on mutations (with triggerRefresh fallback)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:31:08 +00:00
Antigravity
a58610003d fix: remove duplicate useMemo inside handleGenerateTitles (hooks violation = broken save+title), add Save btn + three-dot delete + Ctrl+S to fullPage toolbar 2026-05-07 23:52:21 +00:00
Antigravity
29e65038b7 fix: title textarea auto-resize (no overflow), auto-title 10-word check + toast feedback, AI expand = fixed overlay (no compress) 2026-05-07 23:39:19 +00:00
Antigravity
38c637cfac fix: restore Expand/Minimize button in AI panel + dynamic width, note thumbnail SVG placeholder with emoji/letter 2026-05-07 23:33:20 +00:00
Antigravity
3b036e84b8 fix: auto-title button always visible on hover + wired to AI panel Wand button, title text-4xl/5xl no overflow, content max-w-3xl 2026-05-07 23:29:18 +00:00
Antigravity
ea62d68cdd fix: sticky header needs overflow-y-auto parent, textarea auto-resize on preview toggle, markdown source always visible 2026-05-07 23:09:45 +00:00
Antigravity
77d6458946 fix: AI/Info panels h-full self-stretch fill screen, bg-background consistent, items-stretch on parent 2026-05-07 23:02:20 +00:00
Antigravity
8d4e4d5d56 fix: fullpage editor large text via .fullpage-editor CSS, AI tabs no-wrap, tone selector stronger style, doc-info panel redesign 2026-05-07 22:59:26 +00:00
Antigravity
24b5d6bdac ux: textarea auto-grow, preview toggle in toolbar, prose-lg on richtext, no more bottom preview btn 2026-05-07 22:52:51 +00:00
Antigravity
01390ebb5b fix: header sticky uses bg-background (paper color), sort label hardcoded fallback, border-b added 2026-05-07 22:47:57 +00:00
Antigravity
db899b0da2 fix: content area unified branch (text+markdown→textarea/preview, richtext→RTE), no null dead-end 2026-05-07 22:43:51 +00:00
Antigravity
7bc63158bf fix: textarea 60vh, white header, sidebar corbeille+footer alignment, orphan var removed 2026-05-07 22:43:09 +00:00
Antigravity
ccbd1b5abc feat: fullPage layout 1:1 prototype - white bg, px-12 py-8 toolbar, rounded-full buttons, breadcrumb notebook>date, hero image, prose-lg content 2026-05-07 22:35:52 +00:00
Antigravity
df943878a0 fix: toolbar white bg, rounded-full buttons, px-12 padding like prototype 2026-05-07 22:32:12 +00:00
Antigravity
e458b63115 feat: architectural grid editor fullPage + slash commands + doc info panel + AI title 2026-05-07 22:29:02 +00:00
156 changed files with 10862 additions and 1506 deletions

View File

@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
architectural-grid (2)/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/b7b577c6-4d9f-44ac-8fe1-85bc3c6d6e66
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{
"name": "Architectural Grid",
"description": "A minimalist notebook for architectural research and conceptual sketches.",
"requestFramePermissions": [],
"majorCapabilities": []
}

View File

@@ -0,0 +1,34 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}

View File

@@ -0,0 +1,579 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import {
Plus,
Search,
Share2,
Archive,
Settings,
Lock,
ChevronRight,
MoreVertical,
ArrowLeft
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
// --- Types ---
interface Note {
id: string;
carnetId: string;
title: string;
content: string;
imageUrl: string;
date: string;
}
interface Carnet {
id: string;
name: string;
initial: string;
type: 'Private' | 'Project' | 'Shared';
isPrivate?: boolean;
}
// --- Mock Data ---
const CARNETS: Carnet[] = [
{ id: '1', name: 'Daily Notes', initial: 'D', type: 'Private', isPrivate: true },
{ id: '2', name: 'Project: Neo', initial: 'P', type: 'Project' },
{ id: '3', name: 'Shared Docs', initial: 'S', type: 'Shared' },
{ id: '4', name: 'Architecture Research', initial: 'A', type: 'Project' },
];
const ALL_NOTES: Note[] = [
{
id: 'n1',
carnetId: '4',
title: 'Grid Systems',
date: 'Oct 26, 2024',
content: 'Grid Systems is streathen in ognitiacs clesign and simulhere desipmalt: complded structurer and manamateriai-s: ci arevenuatingly used, asiller straterty of insaee to the tmn and usaes of disrension, architecture of emiornabious tracious structures.',
imageUrl: 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600'
},
{
id: 'n2',
carnetId: '4',
title: 'Materiality',
date: 'Oct 24, 2024',
content: 'Materiality is combinated by relliaitic structureirs measure of plastics, natural, materials and priotical structures. Materialed coasts erabiocera alann light spaces and octicm employed design on thodolen of materiality, and tohlite tersev/ used in the gridin structures en obain materials, coms pathetic structure.',
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600'
},
{
id: 'n3',
carnetId: '4',
title: 'Light & Space',
date: 'Oct 22, 2024',
content: 'Light & Space is a creaivity of light & Space inralicated in sizazant or dark crotrcning and netrescenations of avant trurme sivonpaltures for in inncr-en allimativefiting is cerriadating and sityle.',
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600'
},
{
id: 'n4',
carnetId: '2',
title: 'Neo-Brutalism study',
date: 'Sep 12, 2024',
content: 'Exploring the raw aesthetic of neo-brutalism in urban environments. Focus on concrete textures and massive forms.',
imageUrl: 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&q=80&w=800&h=600'
}
];
// --- Components ---
interface NoteLinkProps {
note: Note;
isActive: boolean;
onClick: () => void;
}
const NoteLink: React.FC<NoteLinkProps> = ({ note, isActive, onClick }) => (
<motion.button
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
onClick={onClick}
className={`w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg
${isActive ? 'bg-white/50 text-ink font-medium' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
>
<div className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-ink' : 'bg-transparent border border-muted-ink/30'}`} />
<span className="truncate">{note.title}</span>
</motion.button>
);
interface SidebarItemProps {
carnet: Carnet;
isActive: boolean;
notes: Note[];
activeNoteId: string | null;
onCarnetClick: () => void;
onNoteClick: (noteId: string) => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({
carnet,
isActive,
notes,
activeNoteId,
onCarnetClick,
onNoteClick
}) => {
return (
<div className="space-y-1">
<motion.button
whileHover={{ x: 4 }}
onClick={() => {
onCarnetClick();
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
${isActive ? 'active-nav-item' : 'hover:bg-white/40'}`}
>
<motion.div
animate={{ rotate: isActive ? 90 : 0 }}
className="text-muted-ink"
>
<ChevronRight size={14} />
</motion.div>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border
${isActive ? 'bg-ink text-paper border-ink' : 'bg-white/60 text-ink border-border'}`}>
{carnet.initial}
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span className={`text-[13px] font-medium transition-colors ${isActive ? 'text-ink' : 'text-muted-ink'}`}>
{carnet.name}
</span>
{carnet.isPrivate && <Lock size={10} className="text-muted-ink" />}
</div>
</div>
</motion.button>
<AnimatePresence>
{isActive && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden space-y-0.5"
>
{notes.map(note => (
<NoteLink
key={note.id}
note={note}
isActive={activeNoteId === note.id}
onClick={() => onNoteClick(note.id)}
/>
))}
{notes.length === 0 && (
<p className="pl-12 text-[11px] text-muted-ink/50 py-2 italic font-light">No notes yet</p>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default function App() {
const [carnets, setCarnets] = useState<Carnet[]>(CARNETS);
const [notes, setNotes] = useState<Note[]>(ALL_NOTES);
const [activeCarnetId, setActiveCarnetId] = useState('4');
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
// Modal States
const [showNewCarnetModal, setShowNewCarnetModal] = useState(false);
const [showNewNoteModal, setShowNewNoteModal] = useState(false);
// Form States
const [newCarnetName, setNewCarnetName] = useState('');
const [newNoteTitle, setNewNoteTitle] = useState('');
const [newNoteContent, setNewNoteContent] = useState('');
const filteredNotes = useMemo(() =>
notes.filter(n => n.carnetId === activeCarnetId),
[activeCarnetId, notes]);
const activeNote = useMemo(() =>
notes.find(n => n.id === activeNoteId),
[activeNoteId, notes]);
const activeCarnet = useMemo(() =>
carnets.find(c => c.id === activeCarnetId),
[activeCarnetId, carnets]);
const handleAddCarnet = (e: React.FormEvent) => {
e.preventDefault();
if (!newCarnetName.trim()) return;
const newCarnet: Carnet = {
id: Date.now().toString(),
name: newCarnetName,
initial: newCarnetName.charAt(0).toUpperCase(),
type: 'Project'
};
setCarnets([...carnets, newCarnet]);
setNewCarnetName('');
setShowNewCarnetModal(false);
setActiveCarnetId(newCarnet.id);
};
const handleAddNote = (e: React.FormEvent) => {
e.preventDefault();
if (!newNoteTitle.trim() || !newNoteContent.trim()) return;
const newNote: Note = {
id: `n-${Date.now()}`,
carnetId: activeCarnetId,
title: newNoteTitle,
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
content: newNoteContent,
imageUrl: 'https://images.unsplash.com/photo-1487958449943-2429e8be8625?auto=format&fit=crop&q=80&w=800&h=600'
};
setNotes([newNote, ...notes]);
setNewNoteTitle('');
setNewNoteContent('');
setShowNewNoteModal(false);
setActiveNoteId(newNote.id);
};
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-8 lg:p-12 overflow-hidden">
<div className="absolute inset-0 z-0 bg-[#DEDEDE]" />
<div className="relative w-full max-w-7xl h-[85vh] flex rounded-2xl overflow-hidden shadow-2xl sidebar-shadow bg-paper">
<div className="absolute -right-4 -bottom-4 w-full h-full bg-white/40 rounded-2xl -z-10 translate-x-2 translate-y-2 opacity-50" />
<div className="absolute -right-2 -bottom-2 w-full h-full bg-white/60 rounded-2xl -z-10 translate-x-1 translate-y-1 opacity-70" />
<aside className="w-80 bg-white/30 backdrop-blur-md border-right border-border p-6 flex flex-col z-20 shrink-0">
<div className="mb-10">
<div className="w-10 h-10 rounded-full bg-slate-200 border border-border flex items-center justify-center text-ink font-serif text-lg shadow-sm">
A
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar">
<div>
<p className="text-[10px] font-bold text-muted-ink tracking-widest uppercase mb-4 px-4">
Architecture Grid
</p>
<div className="space-y-1">
{carnets.map(carnet => (
<SidebarItem
key={carnet.id}
carnet={carnet}
isActive={activeCarnetId === carnet.id}
notes={notes.filter(n => n.carnetId === carnet.id)}
activeNoteId={activeNoteId}
onCarnetClick={() => {
setActiveCarnetId(carnet.id);
setActiveNoteId(null);
}}
onNoteClick={(id) => {
setActiveCarnetId(carnet.id);
setActiveNoteId(id);
}}
/>
))}
</div>
<button
onClick={() => setShowNewCarnetModal(true)}
className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium rounded-lg hover:bg-white/40"
>
<Plus size={16} />
<span>New Carnet</span>
</button>
</div>
</div>
<div className="pt-6 border-t border-border space-y-4">
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
<Archive size={16} />
<span>Archive</span>
</button>
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
<Settings size={16} />
<span>Settings</span>
</button>
</div>
</aside>
<main className="flex-1 paper-texture relative overflow-hidden z-10 flex flex-col h-full">
<AnimatePresence mode="wait">
{!activeNoteId ? (
<motion.div
key="notebook"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex flex-col overflow-y-auto"
>
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-paper/80 backdrop-blur-md z-30">
<div className="flex justify-between items-start">
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink leading-tight pr-12">
{activeCarnet?.name} {filteredNotes[0]?.date || 'Oct 26'}
</h1>
</div>
<div className="flex items-center justify-between border-b border-ink/5 pb-4">
<div className="flex items-center gap-6">
<button
onClick={() => setShowNewNoteModal(true)}
className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity"
>
<Plus size={16} />
<span>Add Note</span>
</button>
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
<Search size={16} />
<span>Search</span>
</button>
</div>
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
<Share2 size={16} />
<span>Share</span>
</button>
</div>
</header>
<div className="px-12 flex-1 pb-20">
<div className="max-w-3xl space-y-16">
{filteredNotes.map((note, index) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 * index, duration: 0.8 }}
key={note.id}
className="space-y-4 group cursor-pointer"
onClick={() => setActiveNoteId(note.id)}
>
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
{note.title}
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
<ChevronRight size={20} />
</button>
</h2>
<div className="flex flex-col md:flex-row gap-8 items-start">
<div className="w-full md:w-56 aspect-[4/3] bg-white/50 border border-border overflow-hidden rounded shadow-sm flex-shrink-0">
<img
src={note.imageUrl}
alt={note.title}
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
referrerPolicy="no-referrer"
/>
</div>
<div className="space-y-3">
<p className="text-[14px] leading-relaxed text-ink/80 font-light max-w-lg line-clamp-4">
{note.content}
</p>
<span className="text-[11px] text-muted-ink uppercase tracking-widest font-medium">Read more</span>
</div>
</div>
</motion.div>
))}
{filteredNotes.length === 0 && (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<p className="font-serif text-xl italic text-muted-ink">This notebook is waiting for its first vision.</p>
<button
onClick={() => setShowNewNoteModal(true)}
className="px-6 py-2 border border-ink text-[13px] uppercase tracking-[0.2em] hover:bg-ink hover:text-paper transition-all"
>
Begin Drawing
</button>
</div>
)}
</div>
</div>
<footer className="px-12 py-6 border-t border-ink/5 text-center mt-auto">
<p className="text-[11px] text-muted-ink uppercase tracking-[0.2em] font-medium">
&copy; 2024 Architectural Grid. All rights reserved.
</p>
</footer>
</motion.div>
) : (
<motion.div
key="focused-note"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02 }}
className="h-full flex flex-col overflow-y-auto bg-white"
>
<div className="flex-1 flex flex-col">
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 backdrop-blur-sm z-40 border-b border-border">
<button
onClick={() => setActiveNoteId(null)}
className="flex items-center gap-2 text-ink hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">Back to collection</span>
</button>
<div className="flex items-center gap-4">
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
<Share2 size={18} />
</button>
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
<MoreVertical size={18} />
</button>
</div>
</div>
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
<div className="space-y-4">
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
<span>{activeCarnet?.name}</span>
<ChevronRight size={10} />
<span>{activeNote?.date}</span>
</div>
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
{activeNote?.title}
</h1>
</div>
<div className="aspect-[16/9] w-full bg-slate-100 rounded-xl overflow-hidden shadow-xl">
<img
src={activeNote?.imageUrl}
alt={activeNote?.title}
className="w-full h-full object-cover grayscale contrast-110"
referrerPolicy="no-referrer"
/>
</div>
<div className="max-w-2xl mx-auto space-y-8 pb-32">
<p className="text-xl md:text-2xl font-serif leading-relaxed text-ink italic">
{activeNote?.content.split('.')[0]}.
</p>
<div className="h-px bg-border w-32" />
<p className="text-lg leading-relaxed text-ink/80 font-light space-y-4 text-justify whitespace-pre-line">
{activeNote?.content}
{activeNote?.id.startsWith('n-') && (
<>
<br /><br />
Architectural grids serve as the invisible scaffolding upon which spatial experiences are constructed. Beyond mere structural repetition, they facilitate a rhythmic dialogue between materiality and void. In this exploration, we examine how light fractures these rigid boundaries, creating a dynamic interplay that evolves with the passage of time.
</>
)}
</p>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
</div>
{/* Modals */}
<AnimatePresence>
{showNewCarnetModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowNewCarnetModal(false)}
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-md bg-paper border border-border shadow-2xl rounded-2xl p-8"
>
<h3 className="text-2xl font-serif font-medium text-ink mb-6">Create New Carnet</h3>
<form onSubmit={handleAddCarnet} className="space-y-6">
<div>
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Notebook Name</label>
<input
autoFocus
type="text"
value={newCarnetName}
onChange={(e) => setNewCarnetName(e.target.value)}
placeholder="E.g., Sustainable Patterns"
className="w-full bg-white border border-border rounded-lg px-4 py-3 outline-none focus:border-ink transition-colors font-serif italic text-lg"
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => setShowNewCarnetModal(false)}
className="flex-1 py-3 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-3 bg-ink text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
>
Create Notebook
</button>
</div>
</form>
</motion.div>
</div>
)}
{showNewNoteModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowNewNoteModal(false)}
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-2xl bg-paper border border-border shadow-2xl rounded-2xl p-10"
>
<h3 className="text-3xl font-serif font-medium text-ink mb-8">Add Architectural Note</h3>
<form onSubmit={handleAddNote} className="space-y-8">
<div>
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Concept Title</label>
<input
autoFocus
type="text"
value={newNoteTitle}
onChange={(e) => setNewNoteTitle(e.target.value)}
placeholder="Enter the title of your study..."
className="w-full bg-white border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-serif text-2xl"
/>
</div>
<div>
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Observations & Analysis</label>
<textarea
value={newNoteContent}
onChange={(e) => setNewNoteContent(e.target.value)}
placeholder="Describe the spatial logic, materiality, and light interactions..."
rows={6}
className="w-full bg-white border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-light leading-relaxed resize-none"
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => setShowNewNoteModal(false)}
className="flex-1 py-4 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-4 bg-ink text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
>
Save Note
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,52 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Playfair Display", serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--color-paper: #F2F0E9;
--color-ink: #1C1C1C;
--color-muted-ink: rgba(28, 28, 28, 0.6);
--color-border: rgba(28, 28, 28, 0.1);
}
@layer base {
body {
@apply bg-[#E5E2D9] text-ink font-sans antialiased;
}
}
.paper-texture {
background-color: var(--color-paper);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
}
/* Custom Scrollbar - Architectural Minimalist */
.custom-scrollbar::-webkit-scrollbar {
width: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.05);
border-radius: 10px;
}
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.15);
}
.sidebar-shadow {
box-shadow: 1px 0 10px rgba(0, 0, 0, 0.05);
}
.active-nav-item {
background: linear-gradient(to right, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
}

View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
});

View File

@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
architectural-grid (3)/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/b7b577c6-4d9f-44ac-8fe1-85bc3c6d6e66
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{
"name": "Architectural Grid",
"description": "A minimalist notebook for architectural research and conceptual sketches.",
"requestFramePermissions": [],
"majorCapabilities": []
}

View File

View File

@@ -0,0 +1,34 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}

View File

@@ -0,0 +1,936 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import {
Plus,
Search,
Share2,
Archive,
Settings,
Lock,
ChevronRight,
MoreVertical,
ArrowLeft,
Sparkles,
MessageSquare,
Wand2,
FileCode,
Globe,
Send,
RefreshCw,
Clock,
BookOpen,
Layout,
Scissors,
Zap,
Languages,
ArrowRightLeft,
History
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
// --- Types ---
type AITone = 'Professional' | 'Creative' | 'Academic' | 'Casual';
type AITab = 'discussion' | 'actions' | 'resources';
interface Note {
id: string;
carnetId: string;
title: string;
content: string;
imageUrl: string;
date: string;
}
interface Carnet {
id: string;
name: string;
initial: string;
type: 'Private' | 'Project' | 'Shared';
isPrivate?: boolean;
}
// --- Mock Data ---
const CARNETS: Carnet[] = [
{ id: '1', name: 'Daily Notes', initial: 'D', type: 'Private', isPrivate: true },
{ id: '2', name: 'Project: Neo', initial: 'P', type: 'Project' },
{ id: '3', name: 'Shared Docs', initial: 'S', type: 'Shared' },
{ id: '4', name: 'Architecture Research', initial: 'A', type: 'Project' },
];
const ALL_NOTES: Note[] = [
{
id: 'n1',
carnetId: '4',
title: 'Grid Systems',
date: 'Oct 26, 2024',
content: 'Grid Systems is streathen in ognitiacs clesign and simulhere desipmalt: complded structurer and manamateriai-s: ci arevenuatingly used, asiller straterty of insaee to the tmn and usaes of disrension, architecture of emiornabious tracious structures.',
imageUrl: 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600'
},
{
id: 'n2',
carnetId: '4',
title: 'Materiality',
date: 'Oct 24, 2024',
content: 'Materiality is combinated by relliaitic structureirs measure of plastics, natural, materials and priotical structures. Materialed coasts erabiocera alann light spaces and octicm employed design on thodolen of materiality, and tohlite tersev/ used in the gridin structures en obain materials, coms pathetic structure.',
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600'
},
{
id: 'n3',
carnetId: '4',
title: 'Light & Space',
date: 'Oct 22, 2024',
content: 'Light & Space is a creaivity of light & Space inralicated in sizazant or dark crotrcning and netrescenations of avant trurme sivonpaltures for in inncr-en allimativefiting is cerriadating and sityle.',
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600'
},
{
id: 'n4',
carnetId: '2',
title: 'Neo-Brutalism study',
date: 'Sep 12, 2024',
content: 'Exploring the raw aesthetic of neo-brutalism in urban environments. Focus on concrete textures and massive forms.',
imageUrl: 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&q=80&w=800&h=600'
}
];
// --- Components ---
interface NoteLinkProps {
note: Note;
isActive: boolean;
onClick: () => void;
}
const NoteLink: React.FC<NoteLinkProps> = ({ note, isActive, onClick }) => (
<motion.button
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
onClick={onClick}
className={`w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg
${isActive ? 'bg-white/50 text-ink font-medium' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
>
<div className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-ink' : 'bg-transparent border border-muted-ink/30'}`} />
<span className="truncate">{note.title}</span>
</motion.button>
);
interface SidebarItemProps {
carnet: Carnet;
isActive: boolean;
notes: Note[];
activeNoteId: string | null;
onCarnetClick: () => void;
onNoteClick: (noteId: string) => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({
carnet,
isActive,
notes,
activeNoteId,
onCarnetClick,
onNoteClick
}) => {
return (
<div className="space-y-1">
<motion.button
whileHover={{ x: 4 }}
onClick={() => {
onCarnetClick();
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
${isActive ? 'active-nav-item' : 'hover:bg-white/40'}`}
>
<motion.div
animate={{ rotate: isActive ? 90 : 0 }}
className="text-muted-ink"
>
<ChevronRight size={14} />
</motion.div>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border
${isActive ? 'bg-ink text-paper border-ink' : 'bg-white/60 text-ink border-border'}`}>
{carnet.initial}
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span className={`text-[13px] font-medium transition-colors ${isActive ? 'text-ink' : 'text-muted-ink'}`}>
{carnet.name}
</span>
{carnet.isPrivate && <Lock size={10} className="text-muted-ink" />}
</div>
</div>
</motion.button>
<AnimatePresence>
{isActive && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden space-y-0.5"
>
{notes.map(note => (
<NoteLink
key={note.id}
note={note}
isActive={activeNoteId === note.id}
onClick={() => onNoteClick(note.id)}
/>
))}
{notes.length === 0 && (
<p className="pl-12 text-[11px] text-muted-ink/50 py-2 italic font-light">No notes yet</p>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default function App() {
const [carnets, setCarnets] = useState<Carnet[]>(CARNETS);
const [notes, setNotes] = useState<Note[]>(ALL_NOTES);
const [activeCarnetId, setActiveCarnetId] = useState('4');
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
const [isAISidebarOpen, setIsAISidebarOpen] = useState(false);
const [aiTab, setAiTab] = useState<AITab>('discussion');
const [selectedTone, setSelectedTone] = useState<AITone>('Professional');
// Modal States
const [showNewCarnetModal, setShowNewCarnetModal] = useState(false);
const [showNewNoteModal, setShowNewNoteModal] = useState(false);
// Form States
const [newCarnetName, setNewCarnetName] = useState('');
const [newNoteTitle, setNewNoteTitle] = useState('');
const [newNoteContent, setNewNoteContent] = useState('');
const filteredNotes = useMemo(() =>
notes.filter(n => n.carnetId === activeCarnetId),
[activeCarnetId, notes]);
const activeNote = useMemo(() =>
notes.find(n => n.id === activeNoteId),
[activeNoteId, notes]);
const activeCarnet = useMemo(() =>
carnets.find(c => c.id === activeCarnetId),
[activeCarnetId, carnets]);
const handleAddCarnet = (e: React.FormEvent) => {
e.preventDefault();
if (!newCarnetName.trim()) return;
const newCarnet: Carnet = {
id: Date.now().toString(),
name: newCarnetName,
initial: newCarnetName.charAt(0).toUpperCase(),
type: 'Project'
};
setCarnets([...carnets, newCarnet]);
setNewCarnetName('');
setShowNewCarnetModal(false);
setActiveCarnetId(newCarnet.id);
};
const handleAddNote = (e: React.FormEvent) => {
e.preventDefault();
if (!newNoteTitle.trim() || !newNoteContent.trim()) return;
const newNote: Note = {
id: `n-${Date.now()}`,
carnetId: activeCarnetId,
title: newNoteTitle,
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
content: newNoteContent,
imageUrl: 'https://images.unsplash.com/photo-1487958449943-2429e8be8625?auto=format&fit=crop&q=80&w=800&h=600'
};
setNotes([newNote, ...notes]);
setNewNoteTitle('');
setNewNoteContent('');
setShowNewNoteModal(false);
setActiveNoteId(newNote.id);
};
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-8 lg:p-12 overflow-hidden">
<div className="absolute inset-0 z-0 bg-[#DEDEDE]" />
<div className="relative w-full max-w-7xl h-[85vh] flex rounded-2xl overflow-hidden shadow-2xl sidebar-shadow bg-paper">
<div className="absolute -right-4 -bottom-4 w-full h-full bg-white/40 rounded-2xl -z-10 translate-x-2 translate-y-2 opacity-50" />
<div className="absolute -right-2 -bottom-2 w-full h-full bg-white/60 rounded-2xl -z-10 translate-x-1 translate-y-1 opacity-70" />
<aside className="w-80 bg-white/30 backdrop-blur-md border-right border-border p-6 flex flex-col z-20 shrink-0">
<div className="mb-10">
<div className="w-10 h-10 rounded-full bg-slate-200 border border-border flex items-center justify-center text-ink font-serif text-lg shadow-sm">
A
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar">
<div>
<p className="text-[10px] font-bold text-muted-ink tracking-widest uppercase mb-4 px-4">
Architecture Grid
</p>
<div className="space-y-1">
{carnets.map(carnet => (
<SidebarItem
key={carnet.id}
carnet={carnet}
isActive={activeCarnetId === carnet.id}
notes={notes.filter(n => n.carnetId === carnet.id)}
activeNoteId={activeNoteId}
onCarnetClick={() => {
setActiveCarnetId(carnet.id);
setActiveNoteId(null);
}}
onNoteClick={(id) => {
setActiveCarnetId(carnet.id);
setActiveNoteId(id);
}}
/>
))}
</div>
<button
onClick={() => setShowNewCarnetModal(true)}
className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium rounded-lg hover:bg-white/40"
>
<Plus size={16} />
<span>New Carnet</span>
</button>
</div>
</div>
<div className="pt-6 border-t border-border space-y-4">
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
<Archive size={16} />
<span>Archive</span>
</button>
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
<Settings size={16} />
<span>Settings</span>
</button>
</div>
</aside>
<main className="flex-1 paper-texture relative overflow-hidden z-10 flex flex-col h-full">
<AnimatePresence mode="wait">
{!activeNoteId ? (
<motion.div
key="notebook"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex flex-col overflow-y-auto"
>
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-paper/80 backdrop-blur-md z-30">
<div className="flex justify-between items-start">
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink leading-tight pr-12">
{activeCarnet?.name} {filteredNotes[0]?.date || 'Oct 26'}
</h1>
</div>
<div className="flex items-center justify-between border-b border-ink/5 pb-4">
<div className="flex items-center gap-6">
<button
onClick={() => setShowNewNoteModal(true)}
className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity"
>
<Plus size={16} />
<span>Add Note</span>
</button>
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
<Search size={16} />
<span>Search</span>
</button>
</div>
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
<Share2 size={16} />
<span>Share</span>
</button>
</div>
</header>
<div className="px-12 flex-1 pb-20">
<div className="max-w-3xl space-y-16">
{filteredNotes.map((note, index) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 * index, duration: 0.8 }}
key={note.id}
className="space-y-4 group cursor-pointer"
onClick={() => setActiveNoteId(note.id)}
>
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
{note.title}
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
<ChevronRight size={20} />
</button>
</h2>
<div className="flex flex-col md:flex-row gap-8 items-start">
<div className="w-full md:w-56 aspect-[4/3] bg-white/50 border border-border overflow-hidden rounded shadow-sm flex-shrink-0">
<img
src={note.imageUrl}
alt={note.title}
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
referrerPolicy="no-referrer"
/>
</div>
<div className="space-y-3">
<p className="text-[14px] leading-relaxed text-ink/80 font-light max-w-lg line-clamp-4">
{note.content}
</p>
<span className="text-[11px] text-muted-ink uppercase tracking-widest font-medium">Read more</span>
</div>
</div>
</motion.div>
))}
{filteredNotes.length === 0 && (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<p className="font-serif text-xl italic text-muted-ink">This notebook is waiting for its first vision.</p>
<button
onClick={() => setShowNewNoteModal(true)}
className="px-6 py-2 border border-ink text-[13px] uppercase tracking-[0.2em] hover:bg-ink hover:text-paper transition-all"
>
Begin Drawing
</button>
</div>
)}
</div>
</div>
<footer className="px-12 py-6 border-t border-ink/5 text-center mt-auto">
<p className="text-[11px] text-muted-ink uppercase tracking-[0.2em] font-medium">
&copy; 2024 Architectural Grid. All rights reserved.
</p>
</footer>
</motion.div>
) : (
<motion.div
key="focused-note"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02 }}
className="h-full flex flex-col overflow-y-auto bg-white"
>
<div className="flex-1 flex overflow-hidden transition-all duration-500">
<div className="flex-1 flex flex-col overflow-y-auto bg-white">
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 backdrop-blur-sm z-40 border-b border-border">
<button
onClick={() => setActiveNoteId(null)}
className="flex items-center gap-2 text-ink hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">Back to collection</span>
</button>
<div className="flex items-center gap-4">
<button
onClick={() => setIsAISidebarOpen(!isAISidebarOpen)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300
${isAISidebarOpen ? 'bg-ink text-paper border-ink' : 'border-border text-ink hover:bg-white/50'}`}
>
<Sparkles size={16} />
<span className="text-xs font-medium">AI Assistant</span>
</button>
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
<Share2 size={18} />
</button>
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
<MoreVertical size={18} />
</button>
</div>
</div>
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
<div className="space-y-4">
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
<span>{activeCarnet?.name}</span>
<ChevronRight size={10} />
<span>{activeNote?.date}</span>
</div>
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
{activeNote?.title}
</h1>
</div>
<div className="aspect-[16/9] w-full bg-slate-100 rounded-xl overflow-hidden shadow-xl">
<img
src={activeNote?.imageUrl}
alt={activeNote?.title}
className="w-full h-full object-cover grayscale contrast-110"
referrerPolicy="no-referrer"
/>
</div>
<div className="max-w-2xl mx-auto space-y-8 pb-32">
<p className="text-xl md:text-2xl font-serif leading-relaxed text-ink italic">
{activeNote?.content.split('.')[0]}.
</p>
<div className="h-px bg-border w-32" />
<p className="text-lg leading-relaxed text-ink/80 font-light space-y-4 text-justify whitespace-pre-line">
{activeNote?.content}
{activeNote?.id.startsWith('n-') && (
<>
<br /><br />
Architectural grids serve as the invisible scaffolding upon which spatial experiences are constructed. Beyond mere structural repetition, they facilitate a rhythmic dialogue between materiality and void. In this exploration, we examine how light fractures these rigid boundaries, creating a dynamic interplay that evolves with the passage of time.
</>
)}
</p>
</div>
</div>
</div>
<AnimatePresence>
{isAISidebarOpen && (
<motion.aside
initial={{ x: 400, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 400, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-[400px] border-l border-border bg-white shadow-2xl flex flex-col z-50 shrink-0 relative"
>
{/* Sidebar Header */}
<div className="p-6 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<h3 className="flex items-center gap-2 font-serif text-xl font-medium text-ink">
<Sparkles size={18} className="text-amber-500" />
IA Note
</h3>
<button
onClick={() => setIsAISidebarOpen(false)}
className="p-1 hover:bg-slate-100 rounded-full transition-colors text-muted-ink"
>
<ChevronRight size={20} />
</button>
</div>
<p className="text-[11px] text-muted-ink uppercase tracking-wider font-medium opacity-60 truncate">
"{activeNote?.title}"
</p>
</div>
{/* Tabs Nav */}
<div className="flex border-b border-border px-2">
{(['discussion', 'actions', 'resources'] as AITab[]).map((tab) => (
<button
key={tab}
onClick={() => setAiTab(tab)}
className={`flex-1 py-3 text-[11px] uppercase tracking-widest font-bold transition-all relative
${aiTab === tab ? 'text-ink' : 'text-muted-ink hover:text-ink/60'}`}
>
{tab}
{aiTab === tab && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink"
/>
)}
</button>
))}
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
<AnimatePresence mode="wait">
{aiTab === 'discussion' && (
<motion.div
key="discussion"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-8"
>
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4 text-muted-ink/40">
<div className="w-16 h-16 rounded-full border border-dashed border-muted-ink/20 flex items-center justify-center">
<MessageSquare size={24} />
</div>
<p className="text-xs font-serif italic leading-relaxed px-8">Posez une question à l'Assistant pour commencer.</p>
</div>
<div className="space-y-4">
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Contexte</label>
<div className="w-full p-3 bg-slate-50 border border-border rounded-lg text-xs flex items-center justify-between cursor-pointer hover:bg-slate-100 transition-colors">
<div className="flex items-center gap-2">
<FileCode size={14} className="text-muted-ink" />
<span>Cette note</span>
</div>
<ChevronRight size={14} className="rotate-90 text-muted-ink" />
</div>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Ton d'écriture</label>
<div className="grid grid-cols-2 gap-2">
{(['Professional', 'Creative', 'Academic', 'Casual'] as AITone[]).map((tone) => (
<button
key={tone}
onClick={() => setSelectedTone(tone)}
className={`p-3 rounded-xl border text-[11px] font-medium transition-all
${selectedTone === tone ? 'bg-ink text-paper border-ink' : 'bg-white border-border text-muted-ink hover:border-ink/20'}`}
>
{tone.charAt(0).toUpperCase() + tone.slice(1, 3)}
</button>
))}
</div>
</div>
</div>
</motion.div>
)}
{aiTab === 'actions' && (
<motion.div
key="actions"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-8"
>
<div className="space-y-8">
{/* Transformations Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink whitespace-nowrap">Transformations</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="grid grid-cols-2 gap-2">
{[
{ icon: <Sparkles size={14} />, label: 'Clarifier' },
{ icon: <Scissors size={14} />, label: 'Raccourcir' },
{ icon: <Zap size={14} />, label: 'Améliorer' },
{ icon: <Languages size={14} />, label: 'Traduire' },
].map((action, i) => (
<button
key={i}
className="flex flex-col items-center gap-3 p-4 bg-white border border-border rounded-xl transition-all group hover:border-ink/20"
>
<div className="p-2 rounded-lg bg-slate-50 transition-colors group-hover:bg-ink group-hover:text-paper shadow-sm text-ink/60">
{action.icon}
</div>
<span className="text-[11px] font-bold text-ink/80 uppercase tracking-wider">{action.label}</span>
</button>
))}
<button className="col-span-2 flex items-center justify-center gap-3 py-3 px-4 bg-white border border-border rounded-xl text-[11px] font-bold text-ink/80 hover:bg-slate-50 transition-colors hover:border-ink/20 uppercase tracking-widest">
<FileCode size={14} className="text-muted-ink" />
Convertir en Markdown
</button>
</div>
</div>
{/* Generation Section */}
<div className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink whitespace-nowrap">Generation Tools</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
{/* Presentation Tool */}
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-ink/20 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Layout size={80} className="text-ink" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-ink/70">
<Layout size={18} />
</div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-ink leading-none">Présentation</h5>
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Convertir en slides interactives</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-widest font-bold text-muted-ink/60 px-1">Thème</span>
<select className="w-full bg-slate-50 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-ink/10 transition-all cursor-pointer">
<option>Architectural Mono</option>
<option>Vibrant Tech</option>
<option>Minimal Silk</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-widest font-bold text-muted-ink/60 px-1">Style</span>
<select className="w-full bg-slate-50 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-ink/10 transition-all cursor-pointer">
<option>Professional</option>
<option>Creative</option>
<option>Brutalist</option>
</select>
</div>
</div>
<button className="w-full py-3.5 bg-ink text-paper rounded-xl text-[12px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-ink/10 uppercase tracking-widest">
Générer
<ArrowRightLeft size={14} className="opacity-60" />
</button>
</div>
</div>
{/* Diagram Tool */}
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-ink/20 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<BookOpen size={80} className="text-ink" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-ink/70">
<BookOpen size={18} />
</div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-ink leading-none">Diagramme</h5>
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Visualisation de structure</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-widest font-bold text-muted-ink/60 px-1">Type</span>
<select className="w-full bg-slate-50 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-ink/10 transition-all cursor-pointer">
<option>Logic Flow</option>
<option>Mind Map</option>
<option>Hierarchy</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-widest font-bold text-muted-ink/60 px-1">Style</span>
<select className="w-full bg-slate-50 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-ink/10 transition-all cursor-pointer">
<option>Draft</option>
<option>Polished</option>
<option>Handwritten</option>
</select>
</div>
</div>
<button className="w-full py-3.5 bg-ink text-paper rounded-xl text-[12px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-ink/10 uppercase tracking-widest">
Tracer
<ArrowRightLeft size={14} className="opacity-60" />
</button>
</div>
</div>
</div>
{/* Activity section placeholder */}
<div className="flex flex-col items-center gap-2 opacity-20 py-4">
<History size={16} />
<span className="text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">Auto-Save Enabled</span>
</div>
</div>
</motion.div>
)}
{aiTab === 'resources' && (
<motion.div
key="resources"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-8"
>
<div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">URL (Optionnel)</label>
<div className="relative">
<input type="text" placeholder="https://..." className="w-full bg-slate-50 border border-border rounded-lg pl-3 pr-10 py-3 text-xs outline-none focus:border-ink transition-colors" />
<Globe size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-ink/40" />
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Texte de la ressource</label>
<textarea
rows={8}
placeholder="Collez votre texte ici (markdown, HTML, texte brut...)"
className="w-full bg-slate-50 border border-border rounded-lg p-4 text-xs outline-none focus:border-ink transition-colors resize-none leading-relaxed"
/>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Mode d'intégration</label>
<div className="grid grid-cols-3 gap-2">
{[
{ id: 'replace', label: 'Remplacer', sub: 'Direct, sans IA' },
{ id: 'append', label: 'Compléter', sub: 'Ajoute sans réécrire' },
{ id: 'merge', label: 'Fusionner', sub: 'Réécrit et intègre' },
].map((mode) => (
<button key={mode.id} className={`flex flex-col items-center justify-center p-3 rounded-lg border transition-all text-center ${mode.id === 'append' ? 'bg-emerald-50 border-emerald-500/30 ring-1 ring-emerald-500/10' : 'bg-white border-border hover:bg-slate-50'}`}>
<span className={`text-[11px] font-bold ${mode.id === 'append' ? 'text-emerald-700' : 'text-ink'}`}>{mode.label}</span>
<span className="text-[8px] text-muted-ink opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
</button>
))}
</div>
</div>
<button className="w-full py-4 bg-[#75B2D6] text-white rounded-xl text-sm font-bold flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-blue-200">
<Sparkles size={18} />
Générer l'aperçu
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Chat Input (Sticky bottom for Discussion) */}
<AnimatePresence>
{aiTab === 'discussion' && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="p-6 bg-white border-t border-border"
>
<div className="relative">
<textarea
rows={3}
placeholder="Posez une question sur cette note..."
className="w-full bg-slate-50 border border-border rounded-2xl p-4 pr-12 text-sm outline-none focus:border-ink transition-colors resize-none leading-relaxed font-light"
/>
<div className="absolute right-3 bottom-3 flex gap-2">
<button className="p-2 text-muted-ink hover:text-ink rounded-lg transition-colors">
<Globe size={16} />
</button>
<button className="p-2 bg-[#75B2D6] text-white rounded-lg transition-transform hover:scale-105 active:scale-95 shadow-sm">
<Send size={16} />
</button>
</div>
</div>
<p className="text-[9px] text-muted-ink text-center mt-3 uppercase tracking-widest font-bold opacity-30 italic">Maj+Entrée = nouvelle ligne</p>
</motion.div>
)}
</AnimatePresence>
</motion.aside>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
</div>
{/* Modals */}
<AnimatePresence>
{showNewCarnetModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowNewCarnetModal(false)}
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-md bg-paper border border-border shadow-2xl rounded-2xl p-8"
>
<h3 className="text-2xl font-serif font-medium text-ink mb-6">Create New Carnet</h3>
<form onSubmit={handleAddCarnet} className="space-y-6">
<div>
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Notebook Name</label>
<input
autoFocus
type="text"
value={newCarnetName}
onChange={(e) => setNewCarnetName(e.target.value)}
placeholder="E.g., Sustainable Patterns"
className="w-full bg-white border border-border rounded-lg px-4 py-3 outline-none focus:border-ink transition-colors font-serif italic text-lg"
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => setShowNewCarnetModal(false)}
className="flex-1 py-3 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-3 bg-ink text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
>
Create Notebook
</button>
</div>
</form>
</motion.div>
</div>
)}
{showNewNoteModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowNewNoteModal(false)}
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-2xl bg-paper border border-border shadow-2xl rounded-2xl p-10"
>
<h3 className="text-3xl font-serif font-medium text-ink mb-8">Add Architectural Note</h3>
<form onSubmit={handleAddNote} className="space-y-8">
<div>
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Concept Title</label>
<input
autoFocus
type="text"
value={newNoteTitle}
onChange={(e) => setNewNoteTitle(e.target.value)}
placeholder="Enter the title of your study..."
className="w-full bg-white border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-serif text-2xl"
/>
</div>
<div>
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Observations & Analysis</label>
<textarea
value={newNoteContent}
onChange={(e) => setNewNoteContent(e.target.value)}
placeholder="Describe the spatial logic, materiality, and light interactions..."
rows={6}
className="w-full bg-white border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-light leading-relaxed resize-none"
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => setShowNewNoteModal(false)}
className="flex-1 py-4 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-4 bg-ink text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
>
Save Note
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,58 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Playfair Display", serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--color-paper: #F2F0E9;
--color-ink: #1C1C1C;
--color-muted-ink: rgba(28, 28, 28, 0.6);
--color-border: rgba(28, 28, 28, 0.1);
}
@layer base {
body {
@apply bg-[#E5E2D9] text-ink font-sans antialiased;
}
}
.paper-texture {
background-color: var(--color-paper);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
}
/* Custom Scrollbar - Architectural Minimalist */
.custom-scrollbar::-webkit-scrollbar {
width: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.08);
border-radius: 10px;
}
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.2);
}
.ai-glass {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.sidebar-shadow {
box-shadow: 1px 0 10px rgba(0, 0, 0, 0.05);
}
.active-nav-item {
background: linear-gradient(to right, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
}

View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
});

View File

@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
architectural-grid (4)/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/b7b577c6-4d9f-44ac-8fe1-85bc3c6d6e66
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{
"name": "Architectural Grid",
"description": "A minimalist notebook for architectural research and conceptual sketches.",
"requestFramePermissions": [],
"majorCapabilities": []
}

View File

@@ -0,0 +1,34 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Playfair Display", serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--color-paper: #F2F0E9;
--color-ink: #1C1C1C;
--color-muted-ink: rgba(28, 28, 28, 0.6);
--color-border: rgba(28, 28, 28, 0.1);
}
@layer base {
body {
@apply bg-[#E5E2D9] text-ink font-sans antialiased;
}
}
.paper-texture {
background-color: var(--color-paper);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
}
/* Custom Scrollbar - Architectural Minimalist */
.custom-scrollbar::-webkit-scrollbar {
width: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.08);
border-radius: 10px;
}
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.2);
}
.ai-glass {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.sidebar-shadow {
box-shadow: 1px 0 10px rgba(0, 0, 0, 0.05);
}
.active-nav-item {
background: linear-gradient(to right, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
}

View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
});

View File

@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
architectural-grid1/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/b7b577c6-4d9f-44ac-8fe1-85bc3c6d6e66
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Google AI Studio App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
{
"name": "Architectural Grid",
"description": "A minimalist notebook for architectural research and conceptual sketches.",
"requestFramePermissions": [],
"majorCapabilities": []
}

View File

@@ -0,0 +1,34 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
"vite": "^6.2.3",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"motion": "^12.23.24"
},
"devDependencies": {
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
}
}

View File

@@ -0,0 +1,408 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import {
Plus,
Search,
Share2,
Archive,
Settings,
Lock,
ChevronRight,
MoreVertical,
ArrowLeft
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
// --- Types ---
interface Note {
id: string;
carnetId: string;
title: string;
content: string;
imageUrl: string;
date: string;
}
interface Carnet {
id: string;
name: string;
initial: string;
type: 'Private' | 'Project' | 'Shared';
isPrivate?: boolean;
}
// --- Mock Data ---
const CARNETS: Carnet[] = [
{ id: '1', name: 'Daily Notes', initial: 'D', type: 'Private', isPrivate: true },
{ id: '2', name: 'Project: Neo', initial: 'P', type: 'Project' },
{ id: '3', name: 'Shared Docs', initial: 'S', type: 'Shared' },
{ id: '4', name: 'Architecture Research', initial: 'A', type: 'Project' },
];
const ALL_NOTES: Note[] = [
{
id: 'n1',
carnetId: '4',
title: 'Grid Systems',
date: 'Oct 26, 2024',
content: 'Grid Systems is streathen in ognitiacs clesign and simulhere desipmalt: complded structurer and manamateriai-s: ci arevenuatingly used, asiller straterty of insaee to the tmn and usaes of disrension, architecture of emiornabious tracious structures.',
imageUrl: 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600'
},
{
id: 'n2',
carnetId: '4',
title: 'Materiality',
date: 'Oct 24, 2024',
content: 'Materiality is combinated by relliaitic structureirs measure of plastics, natural, materials and priotical structures. Materialed coasts erabiocera alann light spaces and octicm employed design on thodolen of materiality, and tohlite tersev/ used in the gridin structures en obain materials, coms pathetic structure.',
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600'
},
{
id: 'n3',
carnetId: '4',
title: 'Light & Space',
date: 'Oct 22, 2024',
content: 'Light & Space is a creaivity of light & Space inralicated in sizazant or dark crotrcning and netrescenations of avant trurme sivonpaltures for in inncr-en allimativefiting is cerriadating and sityle.',
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600'
},
{
id: 'n4',
carnetId: '2',
title: 'Neo-Brutalism study',
date: 'Sep 12, 2024',
content: 'Exploring the raw aesthetic of neo-brutalism in urban environments. Focus on concrete textures and massive forms.',
imageUrl: 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&q=80&w=800&h=600'
}
];
// --- Components ---
interface NoteLinkProps {
note: Note;
isActive: boolean;
onClick: () => void;
}
const NoteLink: React.FC<NoteLinkProps> = ({ note, isActive, onClick }) => (
<motion.button
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
onClick={onClick}
className={`w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg
${isActive ? 'bg-white/50 text-ink font-medium' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
>
<div className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-ink' : 'bg-transparent border border-muted-ink/30'}`} />
<span className="truncate">{note.title}</span>
</motion.button>
);
interface SidebarItemProps {
carnet: Carnet;
isActive: boolean;
notes: Note[];
activeNoteId: string | null;
onCarnetClick: () => void;
onNoteClick: (noteId: string) => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({
carnet,
isActive,
notes,
activeNoteId,
onCarnetClick,
onNoteClick
}) => {
const [isExpanded, setIsExpanded] = useState(isActive);
React.useEffect(() => {
if (isActive) setIsExpanded(true);
}, [isActive]);
return (
<div className="space-y-1">
<motion.button
whileHover={{ x: 4 }}
onClick={() => {
onCarnetClick();
setIsExpanded(!isExpanded);
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
${isActive ? 'active-nav-item' : 'hover:bg-white/40'}`}
>
<motion.div
animate={{ rotate: isExpanded ? 90 : 0 }}
className="text-muted-ink"
>
<ChevronRight size={14} />
</motion.div>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border
${isActive ? 'bg-ink text-paper border-ink' : 'bg-white/60 text-ink border-border'}`}>
{carnet.initial}
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span className={`text-[13px] font-medium transition-colors ${isActive ? 'text-ink' : 'text-muted-ink'}`}>
{carnet.name}
</span>
{carnet.isPrivate && <Lock size={10} className="text-muted-ink" />}
</div>
</div>
</motion.button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden space-y-0.5"
>
{notes.map(note => (
<NoteLink
key={note.id}
note={note}
isActive={activeNoteId === note.id}
onClick={() => onNoteClick(note.id)}
/>
))}
{notes.length === 0 && (
<p className="pl-12 text-[11px] text-muted-ink/50 py-2 italic font-light">No notes yet</p>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default function App() {
const [activeCarnetId, setActiveCarnetId] = useState('4');
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
const filteredNotes = useMemo(() =>
ALL_NOTES.filter(n => n.carnetId === activeCarnetId),
[activeCarnetId]);
const activeNote = useMemo(() =>
ALL_NOTES.find(n => n.id === activeNoteId),
[activeNoteId]);
const activeCarnet = useMemo(() =>
CARNETS.find(c => c.id === activeCarnetId),
[activeCarnetId]);
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-8 lg:p-12 overflow-hidden">
<div className="absolute inset-0 z-0 bg-[#DEDEDE]" />
<div className="relative w-full max-w-7xl h-[85vh] flex rounded-2xl overflow-hidden shadow-2xl sidebar-shadow bg-paper">
<div className="absolute -right-4 -bottom-4 w-full h-full bg-white/40 rounded-2xl -z-10 translate-x-2 translate-y-2 opacity-50" />
<div className="absolute -right-2 -bottom-2 w-full h-full bg-white/60 rounded-2xl -z-10 translate-x-1 translate-y-1 opacity-70" />
<aside className="w-80 bg-white/30 backdrop-blur-md border-right border-border p-6 flex flex-col z-20 shrink-0">
<div className="mb-10">
<div className="w-10 h-10 rounded-full bg-slate-200 border border-border flex items-center justify-center text-ink font-serif text-lg shadow-sm">
A
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar">
<div>
<p className="text-[10px] font-bold text-muted-ink tracking-widest uppercase mb-4 px-4">
Architecture Grid
</p>
<div className="space-y-1">
{CARNETS.map(carnet => (
<SidebarItem
key={carnet.id}
carnet={carnet}
isActive={activeCarnetId === carnet.id}
notes={ALL_NOTES.filter(n => n.carnetId === carnet.id)}
activeNoteId={activeNoteId}
onCarnetClick={() => {
setActiveCarnetId(carnet.id);
setActiveNoteId(null);
}}
onNoteClick={(id) => {
setActiveCarnetId(carnet.id);
setActiveNoteId(id);
}}
/>
))}
</div>
<button className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
<Plus size={16} />
<span>New Carnet</span>
</button>
</div>
</div>
<div className="pt-6 border-t border-border space-y-4">
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
<Archive size={16} />
<span>Archive</span>
</button>
<button className="flex items-center gap-3 px-4 text-[13px] text-muted-ink hover:text-ink transition-colors font-medium">
<Settings size={16} />
<span>Settings</span>
</button>
</div>
</aside>
<main className="flex-1 paper-texture relative overflow-hidden z-10 flex flex-col h-full">
<AnimatePresence mode="wait">
{!activeNoteId ? (
<motion.div
key="notebook"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full flex flex-col overflow-y-auto"
>
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-paper/80 backdrop-blur-md z-30">
<div className="flex justify-between items-start">
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink leading-tight pr-12">
{activeCarnet?.name} {filteredNotes[0]?.date || 'Oct 26'}
</h1>
</div>
<div className="flex items-center justify-between border-b border-ink/5 pb-4">
<div className="flex items-center gap-6">
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
<Plus size={16} />
<span>Add Note</span>
</button>
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
<Search size={16} />
<span>Search</span>
</button>
</div>
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
<Share2 size={16} />
<span>Share</span>
</button>
</div>
</header>
<div className="px-12 flex-1 pb-20">
<div className="max-w-3xl space-y-16">
{filteredNotes.map((note, index) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 * index, duration: 0.8 }}
key={note.id}
className="space-y-4 group cursor-pointer"
onClick={() => setActiveNoteId(note.id)}
>
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
{note.title}
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
<ChevronRight size={20} />
</button>
</h2>
<div className="flex flex-col md:flex-row gap-8 items-start">
<div className="w-full md:w-56 aspect-[4/3] bg-white/50 border border-border overflow-hidden rounded shadow-sm flex-shrink-0">
<img
src={note.imageUrl}
alt={note.title}
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
referrerPolicy="no-referrer"
/>
</div>
<div className="space-y-3">
<p className="text-[14px] leading-relaxed text-ink/80 font-light max-w-lg line-clamp-4">
{note.content}
</p>
<span className="text-[11px] text-muted-ink uppercase tracking-widest font-medium">Read more</span>
</div>
</div>
</motion.div>
))}
</div>
</div>
<footer className="px-12 py-6 border-t border-ink/5 text-center mt-auto">
<p className="text-[11px] text-muted-ink uppercase tracking-[0.2em] font-medium">
&copy; 2024 Architectural Grid. All rights reserved.
</p>
</footer>
</motion.div>
) : (
<motion.div
key="focused-note"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02 }}
className="h-full flex flex-col overflow-y-auto bg-white"
>
<div className="flex-1 flex flex-col">
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 backdrop-blur-sm z-40 border-b border-border">
<button
onClick={() => setActiveNoteId(null)}
className="flex items-center gap-2 text-ink hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">Back to collection</span>
</button>
<div className="flex items-center gap-4">
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
<Share2 size={18} />
</button>
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
<MoreVertical size={18} />
</button>
</div>
</div>
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
<div className="space-y-4">
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
<span>{activeCarnet?.name}</span>
<ChevronRight size={10} />
<span>{activeNote?.date}</span>
</div>
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
{activeNote?.title}
</h1>
</div>
<div className="aspect-[16/9] w-full bg-slate-100 rounded-xl overflow-hidden shadow-xl">
<img
src={activeNote?.imageUrl}
alt={activeNote?.title}
className="w-full h-full object-cover grayscale contrast-110"
referrerPolicy="no-referrer"
/>
</div>
<div className="max-w-2xl mx-auto space-y-8 pb-32">
<p className="text-xl md:text-2xl font-serif leading-relaxed text-ink italic">
{activeNote?.content.split('.')[0]}.
</p>
<div className="h-px bg-border w-32" />
<p className="text-lg leading-relaxed text-ink/80 font-light space-y-4 text-justify">
{activeNote?.content}
<br /><br />
Architectural grids serve as the invisible scaffolding upon which spatial experiences are constructed. Beyond mere structural repetition, they facilitate a rhythmic dialogue between materiality and void. In this exploration, we examine how light fractures these rigid boundaries, creating a dynamic interplay that evolves with the passage of time.
<br /><br />
The integration of sustainable materials directly into the primary grid allows for a cohesive aesthetic that doesn't compromise on environmental performance. As we transition toward more modular designs, the grid becomes not just a tool for measurement, but a language for expression.
</p>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Playfair Display", serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--color-paper: #F2F0E9;
--color-ink: #1C1C1C;
--color-muted-ink: rgba(28, 28, 28, 0.6);
--color-border: rgba(28, 28, 28, 0.1);
}
@layer base {
body {
@apply bg-[#E5E2D9] text-ink font-sans antialiased;
}
}
.paper-texture {
background-color: var(--color-paper);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
}
.sidebar-shadow {
box-shadow: 1px 0 10px rgba(0, 0, 0, 0.05);
}
.active-nav-item {
background: linear-gradient(to right, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
}

View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
},
};
});

View File

@@ -0,0 +1,60 @@
Redesign the entire UI of this application to look like Windows 11 Fluent Design. This is a UI-only redesign - do NOT change any business logic, API routes, database schema, or functionality. Only modify visual styling (CSS classes, Tailwind utilities, color values, border-radius, shadows, etc.).
Changes needed:
1. globals.css - Update theme:
- Primary color: #0078D4 (Windows 11 blue)
- Add --color-win11-accent: #0078D4 and shades (#106EBE, #005A9E, #003D6B)
- Background: light #f3f3f3, dark #202020
- Rounded corners: 8px cards, 4px small elements
- Shadows: subtle layered shadows like Win11 (0 2px 8px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.08))
- Smooth transitions: 200-300ms ease
- Add acrylic utility: .acrylic { backdrop-filter: blur(20px) saturate(180%); background: rgba(255,255,255,0.7); }
- Add .acrylic-dark for dark mode
2. app/(main)/layout.tsx - Main layout:
- Background: #f3f3f3 (light) / #202020 (dark)
- Sidebar: add bg-white/80 dark:bg-[#2d2d2d]/80 backdrop-blur-xl rounded-e-lg
- Content area: clean with subtle padding
3. components/sidebar.tsx - Windows 11 navigation:
- Semi-transparent bg with backdrop-blur-xl
- Nav items: rounded-lg hover states with subtle bg-slate-100 dark:bg-slate-800
- Active item: bg-blue-50 dark:bg-blue-900/30 text-[#0078D4] with left border-2 indicator
- Smooth collapse animation with transition-all duration-300
4. components/header.tsx - Windows 11 title bar:
- Clean minimal, height h-12
- Rounded search input (rounded-full or rounded-lg) like Win11 search
- Subtle bottom border
5. components/note-card.tsx - Win11 cards:
- rounded-lg (8px)
- border border-slate-200 dark:border-slate-700
- hover:shadow-lg hover:-translate-y-0.5 transition-all duration-200
- Clean white bg
6. components/home-client.tsx - Widget layout:
- Rounded containers (rounded-xl) for each section
- Subtle hover:shadow-md transition
7. components/note-editor.tsx & rich-text-editor.tsx - Win11 editor:
- Rounded toolbar buttons (rounded-md)
- Subtle separators between toolbar groups
- Clean focused state
8. components/ui/button.tsx - Win11 buttons:
- rounded-md (6px)
- Primary: bg-[#0078D4] hover:bg-[#106EBE] text-white
- Secondary: bg-slate-100 hover:bg-slate-200 border border-slate-300
- Subtle active states
9. components/ui/card.tsx - Win11 card:
- rounded-lg border border-slate-200/60
- hover:shadow-md transition-shadow duration-200
10. components/ui/input.tsx - Win11 input:
- rounded-md (6px)
- border-slate-300 focus:border-[#0078D4] focus:ring-1 focus:ring-[#0078D4]/30
Read each file first, understand its structure, then make surgical edits. After all changes, run npm run build to verify the build passes. Fix any build errors if any.

View File

@@ -159,6 +159,7 @@ export function AgentsPageClient({
role: formData.get('role') as string,
sourceUrls: formData.get('sourceUrls') ? JSON.parse(formData.get('sourceUrls') as string) : undefined,
sourceNotebookId: (formData.get('sourceNotebookId') as string) || undefined,
sourceNoteIds: formData.get('sourceNoteIds') ? JSON.parse(formData.get('sourceNoteIds') as string) : undefined,
targetNotebookId: (formData.get('targetNotebookId') as string) || undefined,
frequency: formData.get('frequency') as string,
tools: formData.get('tools') ? JSON.parse(formData.get('tools') as string) : undefined,
@@ -168,6 +169,8 @@ export function AgentsPageClient({
scheduledTime: (formData.get('scheduledTime') as string) || undefined,
scheduledDay: formData.get('scheduledDay') ? Number(formData.get('scheduledDay')) : undefined,
timezone: (formData.get('timezone') as string) || undefined,
slideTheme: (formData.get('slideTheme') as string) || undefined,
slideStyle: (formData.get('slideStyle') as string) || undefined,
}
if (editingAgent) {
await updateAgent(editingAgent.id, data)
@@ -196,70 +199,30 @@ export function AgentsPageClient({
const existingAgentNames = useMemo(() => agents.map(a => a.name), [agents])
return (
/* Full-bleed layout: -m-4 cancels the p-4 of the parent <main> */
<div className="flex -m-4 h-[calc(100vh-4rem)] overflow-hidden">
/* Full-bleed layout */
<div className="flex flex-col h-full overflow-hidden">
{/* ── LEFT SIDEBAR ── */}
<aside className="w-60 flex-shrink-0 flex flex-col bg-muted/30 border-r border-border/40 h-full font-display">
{/* Brand */}
<div className="flex items-center gap-3 px-5 py-5 border-b border-border/40">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4 text-primary-foreground" />
</div>
<span className="font-bold text-base tracking-tight">{t('agents.title')}</span>
</div>
{/* Nav */}
<nav className="flex-1 p-3 space-y-0.5">
<button
onClick={() => setActiveTab('dashboard')}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm font-medium rounded-lg transition-all ${
activeTab === 'dashboard'
? 'bg-primary/10 text-primary border-r-2 border-primary'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'
}`}
>
<Bot className="w-4 h-4" />
{/* ── Top header bar — architectural grid style ── */}
<header className="flex items-center justify-between px-12 py-10 border-b border-border/40 flex-shrink-0">
<div>
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight">
{t('agents.myAgents')}
</button>
</nav>
{/* Footer: Help */}
<div className="p-3 border-t border-border/40">
<button
onClick={() => setShowHelp(true)}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-background/70 hover:text-foreground rounded-lg transition-all"
>
<HelpCircle className="w-4 h-4" />
{t('agents.help.btnLabel')}
</button>
</h1>
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">
{t('agents.subtitle')}
</p>
</div>
</aside>
<button
onClick={handleCreate}
className="flex items-center gap-2 px-6 py-3 text-[13px] font-medium uppercase tracking-[0.12em] border border-foreground text-foreground hover:bg-foreground hover:text-background transition-all"
>
<Plus className="w-4 h-4" />
{t('agents.newAgent')}
</button>
</header>
{/* ── MAIN CONTENT ── */}
<div className="flex-1 flex flex-col min-w-0 bg-background overflow-hidden">
{/* Top header bar */}
<header className="flex items-center justify-between px-8 py-4 border-b border-border/40 bg-background flex-shrink-0 font-display">
<div>
<h1 className="text-xl font-bold tracking-tight">
{t('agents.myAgents')}
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{t('agents.subtitle')}
</p>
</div>
<button
onClick={handleCreate}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg shadow-sm hover:shadow-md hover:shadow-primary/20 transition-all"
>
<Plus className="w-4 h-4" />
{t('agents.newAgent')}
</button>
</header>
{/* Scrollable content area */}
<main className="flex-1 overflow-y-auto p-8">
{/* ── Scrollable content area ── */}
<main className="flex-1 overflow-y-auto px-12 py-10">
{/* Dashboard tab - agents + templates */}
{activeTab === 'dashboard' && (
@@ -329,7 +292,6 @@ export function AgentsPageClient({
</>
)}
</main>
</div>
{/* Sliding panels */}
{showForm && (

View File

@@ -1,5 +1,4 @@
import { Suspense } from "react";
import { HeaderWrapper } from "@/components/header-wrapper";
import { Sidebar } from "@/components/sidebar";
import { ProvidersWrapper } from "@/components/providers-wrapper";
import { auth } from "@/auth";
@@ -7,28 +6,23 @@ import { headers } from "next/headers";
import { detectUserLanguage, parseAcceptLanguage } from "@/lib/i18n/detect-user-language";
import { loadTranslations } from "@/lib/i18n/load-translations";
import { getAISettings } from "@/app/actions/ai-settings";
import { AIChat } from "@/components/ai-chat";
import { AIChatLayoutBridge } from "@/components/ai-chat-layout-bridge";
export default async function MainLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// Read browser language hint from Accept-Language header
const headersList = await headers();
const browserLang = parseAcceptLanguage(headersList.get("accept-language"));
// Run auth + language detection + translation loading in parallel
const [session, initialLanguage] = await Promise.all([
auth(),
detectUserLanguage(browserLang),
]);
// Load initial translations server-side to prevent hydration mismatch
const initialTranslations = await loadTranslations(initialLanguage);
// Load AI settings to conditionally render AI features
const aiSettings = session?.user?.id
? await getAISettings(session.user.id)
: null;
@@ -36,25 +30,17 @@ export default async function MainLayout({
return (
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
<div className="bg-background-light dark:bg-background-dark font-display text-slate-900 dark:text-white overflow-hidden h-screen flex flex-col">
{/* Top Navigation - Style Keep */}
<HeaderWrapper user={session?.user} />
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
<div className="flex h-screen overflow-hidden bg-[#E5E2D9]">
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
<Sidebar user={session?.user} />
</Suspense>
{/* Main Layout */}
<div className="flex flex-1 overflow-hidden relative">
{/* Sidebar Navigation - Style Keep */}
<Suspense fallback={<div className="w-64 flex-none hidden md:flex" />}>
<Sidebar className="w-64 flex-none flex-col bg-white dark:bg-[#1e2128] border-e border-slate-200 dark:border-slate-800 overflow-y-auto hidden md:flex" user={session?.user} />
</Suspense>
<main className="memento-paper-texture flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth">
{children}
</main>
{/* Main Content Area */}
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background-light dark:bg-background-dark p-4 scroll-smooth">
{children}
</main>
{/* AI Chat Drawer — only shown if user has Assistant IA enabled */}
{showAIAssistant && <AIChat />}
</div>
{showAIAssistant && <AIChatLayoutBridge />}
</div>
</ProvidersWrapper>
);

View File

@@ -10,10 +10,12 @@ export default async function HomePage() {
const notesViewMode =
settings?.notesViewMode === 'masonry'
? 'masonry' as const
: settings?.notesViewMode === 'tabs' || settings?.notesViewMode === 'list'
? 'tabs' as const
: 'masonry' as const
? ('masonry' as const)
: settings?.notesViewMode === 'tabs'
? ('tabs' as const)
: settings?.notesViewMode === 'list'
? ('list' as const)
: ('masonry' as const)
return (
<HomeClient

View File

@@ -1,132 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { SettingsSection, SettingSelect } from '@/components/settings'
import { updateAISettings as updateAI } from '@/app/actions/ai-settings'
import { updateUserSettings as updateUser } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
interface AppearanceSettingsFormProps {
initialTheme: string
initialFontSize: string
initialCardSizeMode?: string
}
export function AppearanceSettingsForm({ initialTheme, initialFontSize, initialCardSizeMode = 'variable' }: AppearanceSettingsFormProps) {
const router = useRouter()
const [theme, setTheme] = useState(initialTheme)
const [fontSize, setFontSize] = useState(initialFontSize)
const [cardSizeMode, setCardSizeMode] = useState(initialCardSizeMode)
const { t } = useLanguage()
const handleThemeChange = async (value: string) => {
setTheme(value)
localStorage.setItem('theme-preference', value)
// Instant visual update
const root = document.documentElement
root.removeAttribute('data-theme')
root.classList.remove('dark')
if (value === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
} else if (value === 'dark') {
root.classList.add('dark')
} else if (value === 'light') {
root.setAttribute('data-theme', 'light')
} else {
root.setAttribute('data-theme', value)
if (['midnight', 'blue', 'sepia'].includes(value)) root.classList.add('dark')
}
// Save to DB (no need for router.refresh - localStorage handles immediate visuals)
await updateUser({ theme: value as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue' })
}
const handleFontSizeChange = async (value: string) => {
setFontSize(value)
// Instant visual update
const fontSizeMap: Record<string, string> = {
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
}
const root = document.documentElement
root.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
await updateAI({ fontSize: value as any })
}
const handleCardSizeModeChange = async (value: string) => {
setCardSizeMode(value)
localStorage.setItem('card-size-mode', value)
await updateUser({ cardSizeMode: value as 'variable' | 'uniform' })
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('appearance.description')}
</p>
</div>
<SettingsSection
title={t('settings.theme')}
icon={<span className="text-2xl">🎨</span>}
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
>
<SettingSelect
label={t('settings.theme')}
description={t('settings.selectLanguage')}
value={theme}
options={[
{ value: 'slate', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'blue', label: 'Blue' },
{ value: 'auto', label: t('settings.themeSystem') },
]}
onChange={handleThemeChange}
/>
</SettingsSection>
<SettingsSection
title={t('profile.fontSize')}
icon={<span className="text-2xl">📝</span>}
description={t('profile.fontSizeDescription')}
>
<SettingSelect
label={t('profile.fontSize')}
description={t('profile.selectFontSize')}
value={fontSize}
options={[
{ value: 'small', label: t('profile.fontSizeSmall') },
{ value: 'medium', label: t('profile.fontSizeMedium') },
{ value: 'large', label: t('profile.fontSizeLarge') },
]}
onChange={handleFontSizeChange}
/>
</SettingsSection>
<SettingsSection
title={t('settings.cardSizeMode')}
icon={<span className="text-2xl">📐</span>}
description={t('settings.cardSizeModeDescription')}
>
<SettingSelect
label={t('settings.cardSizeMode')}
description={t('settings.selectCardSizeMode')}
value={cardSizeMode}
options={[
{ value: 'variable', label: t('settings.cardSizeVariable') },
{ value: 'uniform', label: t('settings.cardSizeUniform') },
]}
onChange={handleCardSizeModeChange}
/>
</SettingsSection>
</div>
)
}

View File

@@ -6,11 +6,12 @@ import { updateUserSettings } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { Palette, Type, LayoutGrid, Maximize2 } from 'lucide-react'
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
interface AppearanceSettingsClientProps {
initialFontSize: string
initialTheme: string
initialNotesViewMode: 'masonry' | 'tabs'
initialNotesViewMode: 'masonry' | 'tabs' | 'list'
initialCardSizeMode?: 'variable' | 'uniform'
initialFontFamily?: string
}
@@ -23,27 +24,18 @@ export function AppearanceSettingsClient({
initialFontFamily = 'inter',
}: AppearanceSettingsClientProps) {
const { t } = useLanguage()
const [theme, setTheme] = useState(initialTheme || 'light')
const [theme, setTheme] = useState<ThemeId>(normalizeThemeId(initialTheme || 'light'))
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode)
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs' | 'list'>(initialNotesViewMode)
const [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode)
const [fontFamily, setFontFamily] = useState(initialFontFamily)
const handleThemeChange = async (value: string) => {
setTheme(value)
localStorage.setItem('theme-preference', value)
const root = document.documentElement
root.removeAttribute('data-theme')
root.classList.remove('dark')
if (value === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
} else if (value === 'dark') {
root.classList.add('dark')
} else {
root.setAttribute('data-theme', value)
if (['midnight'].includes(value)) root.classList.add('dark')
}
await updateUserSettings({ theme: value as any })
const next = normalizeThemeId(value)
setTheme(next)
localStorage.setItem('theme-preference', next)
applyDocumentTheme(next)
await updateUserSettings({ theme: next })
toast.success(t('settings.settingsSaved') || 'Saved')
}
@@ -56,7 +48,7 @@ export function AppearanceSettingsClient({
}
const handleNotesViewChange = async (value: string) => {
const mode = value === 'tabs' ? 'tabs' : 'masonry'
const mode = value === 'tabs' ? 'tabs' : value === 'list' ? 'list' : 'masonry'
setNotesViewMode(mode)
await updateAISettings({ notesViewMode: mode })
toast.success(t('settings.settingsSaved') || 'Saved')
@@ -87,12 +79,14 @@ export function AppearanceSettingsClient({
value,
options,
onChange,
optionGroups,
}: {
icon: React.ElementType
title: string
description: string
value: string
options: { value: string; label: string }[]
options?: { value: string; label: string }[]
optionGroups?: { label: string; options: { value: string; label: string }[] }[]
onChange: (v: string) => void
}) => (
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
@@ -111,17 +105,56 @@ export function AppearanceSettingsClient({
onChange={(e) => onChange(e.target.value)}
className="w-full h-11 px-4 bg-muted border border-border rounded-lg text-foreground text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none appearance-none cursor-pointer transition-colors"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
{optionGroups
? optionGroups.map((g) => (
<optgroup key={g.label} label={g.label}>
{g.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</optgroup>
))
: options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
)
const themeOptionGroups = [
{
label: t('settings.themeBaseGroup'),
options: [
{ value: 'light', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'auto', label: t('settings.themeSystem') },
],
},
{
label: t('settings.themePalettesGroup'),
options: [
{ value: 'sepia', label: t('settings.themeSepia') },
{ value: 'midnight', label: t('settings.themeMidnight') },
{ value: 'rose', label: t('settings.themeRose') },
{ value: 'green', label: t('settings.themeGreen') },
{ value: 'lavender', label: t('settings.themeLavender') },
{ value: 'sand', label: t('settings.themeSand') },
{ value: 'ocean', label: t('settings.themeOcean') },
{ value: 'sunset', label: t('settings.themeSunset') },
{ value: 'blue', label: t('settings.themeBlue') },
],
},
]
return (
<div className="space-y-8">
<div>
@@ -135,19 +168,7 @@ export function AppearanceSettingsClient({
title={t('settings.theme')}
description={t('appearance.selectTheme')}
value={theme}
options={[
{ value: 'light', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'rose', label: 'Rose' },
{ value: 'green', label: 'Green' },
{ value: 'lavender', label: 'Lavender' },
{ value: 'sand', label: 'Sand' },
{ value: 'ocean', label: 'Ocean' },
{ value: 'sunset', label: 'Sunset' },
{ value: 'auto', label: t('settings.themeSystem') },
]}
optionGroups={themeOptionGroups}
onChange={handleThemeChange}
/>
@@ -168,7 +189,7 @@ export function AppearanceSettingsClient({
<SelectCard
icon={Type}
title={t('appearance.fontFamilyLabel') || 'Police'}
description={t('appearance.fontFamilyDescription') || 'Choisissez la police de l\'application'}
description={t('appearance.fontFamilyDescription') || "Choisissez la police de l'application"}
value={fontFamily}
options={[
{ value: 'inter', label: 'Inter' },
@@ -184,6 +205,7 @@ export function AppearanceSettingsClient({
value={notesViewMode}
options={[
{ value: 'masonry', label: t('appearance.notesViewMasonry') },
{ value: 'list', label: t('appearance.notesViewList') },
{ value: 'tabs', label: t('appearance.notesViewTabs') },
]}
onChange={handleNotesViewChange}

View File

@@ -19,7 +19,13 @@ export default async function AppearanceSettingsPage() {
<AppearanceSettingsClient
initialFontSize={aiSettings.fontSize}
initialTheme={userSettings.theme}
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
initialNotesViewMode={
aiSettings.notesViewMode === 'masonry'
? 'masonry'
: aiSettings.notesViewMode === 'list'
? 'list'
: 'tabs'
}
initialCardSizeMode={userSettings.cardSizeMode}
initialFontFamily={aiSettings.fontFamily || 'inter'}
/>

View File

@@ -59,13 +59,11 @@ function pickUserAISettingsForDb(input: UserAISettingsData): Partial<Record<User
out[key] = v
}
}
if (out.notesViewMode === 'list') {
out.notesViewMode = 'tabs'
}
if (
out.notesViewMode != null &&
out.notesViewMode !== 'masonry' &&
out.notesViewMode !== 'tabs'
out.notesViewMode !== 'tabs' &&
out.notesViewMode !== 'list'
) {
delete out.notesViewMode
}
@@ -167,9 +165,11 @@ const getCachedAISettings = unstable_cache(
const viewMode =
raw === 'masonry'
? ('masonry' as const)
: raw === 'list' || raw === 'tabs'
: raw === 'tabs'
? ('tabs' as const)
: ('masonry' as const)
: raw === 'list'
? ('list' as const)
: ('masonry' as const)
return {
titleSuggestions: settings.titleSuggestions,

View File

@@ -37,6 +37,7 @@ const NOTE_LIST_SELECT = {
checkItems: true,
labels: true,
images: true,
illustrationSvg: true,
links: true,
reminder: true,
isReminderDone: true,
@@ -789,6 +790,7 @@ export async function updateNote(id: string, data: {
checkItems?: CheckItem[] | null
labels?: string[] | null
images?: string[] | null
illustrationSvg?: string | null
links?: any[] | null
reminder?: Date | null
isMarkdown?: boolean
@@ -844,6 +846,7 @@ export async function updateNote(id: string, data: {
// labels handled by syncNoteLabels below
delete updateData.labels
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
if ('illustrationSvg' in data) updateData.illustrationSvg = data.illustrationSvg
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
if ('notebookId' in data) updateData.notebookId = data.notebookId
// Explicitly handle size to ensure it propagates
@@ -852,7 +855,7 @@ export async function updateNote(id: string, data: {
// Only update contentUpdatedAt for actual content changes, NOT for property changes
// (size, color, isPinned, isArchived are properties, not content)
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
const contentFields = ['title', 'content', 'checkItems', 'images', 'links']
const contentFields = ['title', 'content', 'checkItems', 'images', 'links', 'illustrationSvg']
const isContentChange = contentFields.some(field => field in data)
if (isContentChange && !options?.skipContentTimestamp) {
updateData.contentUpdatedAt = new Date()

View File

@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { normalizeThemeId } from '@/lib/apply-document-theme'
const ProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
@@ -88,10 +89,12 @@ export async function updateTheme(theme: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
const normalized = normalizeThemeId(theme)
try {
await prisma.user.update({
where: { id: session.user.id },
data: { theme },
data: { theme: normalized },
})
revalidatePath('/')
revalidatePath('/settings/profile')

View File

@@ -3,9 +3,11 @@
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath, updateTag } from 'next/cache'
import { unstable_cache } from 'next/cache'
import { normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
export type UserSettingsData = {
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
theme?: ThemeId
cardSizeMode?: 'variable' | 'uniform'
}
@@ -13,7 +15,6 @@ export type UserSettingsData = {
* Update user settings (theme, etc.)
*/
export async function updateUserSettings(settings: UserSettingsData) {
const session = await auth()
if (!session?.user?.id) {
@@ -22,11 +23,14 @@ export async function updateUserSettings(settings: UserSettingsData) {
}
try {
const result = await prisma.user.update({
where: { id: session.user.id },
data: settings
})
const data: { theme?: string; cardSizeMode?: 'variable' | 'uniform' } = {}
if (settings.theme !== undefined) data.theme = normalizeThemeId(settings.theme)
if (settings.cardSizeMode !== undefined) data.cardSizeMode = settings.cardSizeMode
await prisma.user.update({
where: { id: session.user.id },
data,
})
revalidatePath('/', 'layout')
updateTag('user-settings')
@@ -38,28 +42,23 @@ export async function updateUserSettings(settings: UserSettingsData) {
}
}
/**
* Get user settings for current user (Cached)
*/
import { unstable_cache } from 'next/cache'
// Internal cached function
const getCachedUserSettings = unstable_cache(
async (userId: string) => {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { theme: true, cardSizeMode: true }
select: { theme: true, cardSizeMode: true },
})
return {
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue',
cardSizeMode: (user?.cardSizeMode || 'variable') as 'variable' | 'uniform'
theme: normalizeThemeId(user?.theme || 'light'),
cardSizeMode: (user?.cardSizeMode || 'variable') as 'variable' | 'uniform',
}
} catch (error) {
console.error('Error getting user settings:', error)
return {
theme: 'light' as const
theme: 'light' as const satisfies ThemeId,
cardSizeMode: 'variable' as const,
}
}
},
@@ -77,8 +76,8 @@ export async function getUserSettings(userId?: string) {
if (!id) {
return {
theme: 'light' as const,
cardSizeMode: 'variable' as const
theme: 'light' as const satisfies ThemeId,
cardSizeMode: 'variable' as const,
}
}

View File

@@ -1,7 +1,26 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
export async function GET(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const id = req.nextUrl.searchParams.get('id')
if (!id) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 })
}
const canvas = await prisma.canvas.findFirst({
where: { id, userId: session.user.id },
select: { id: true, name: true, data: true, updatedAt: true },
})
if (!canvas) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json({ canvas })
}
export async function POST(req: Request) {
try {

View File

@@ -17,6 +17,8 @@ export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const includeArchived = searchParams.get('archived') === 'true'
const search = searchParams.get('search')
const notebookId = searchParams.get('notebookId')
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined
let where: any = {
userId: session.user.id,
@@ -27,6 +29,10 @@ export async function GET(request: NextRequest) {
where.isArchived = false
}
if (notebookId) {
where.notebookId = notebookId
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
@@ -40,7 +46,8 @@ export async function GET(request: NextRequest) {
{ isPinned: 'desc' },
{ order: 'asc' },
{ updatedAt: 'desc' }
]
],
...(limit ? { take: limit } : {}),
})
return NextResponse.json({

View File

@@ -12,22 +12,32 @@
--breakpoint-large-desktop: 1440px;
--breakpoint-ultra-wide: 1920px;
/* Custom colors matching Keep design */
--color-primary: #64748b;
--color-background-light: #f7f7f8;
--color-background-dark: #1a1d23;
/* Memento — Architectural Grid (réf. architectural-grid1) */
--color-memento-desk: #E5E2D9;
--color-memento-paper: #F2F0E9;
--color-memento-ink: #1C1C1C;
--color-primary: #1C1C1C;
--color-memento-accent: #1C1C1C;
--color-memento-paper-elevated: #faf9f5;
--color-background-light: var(--color-memento-paper);
--color-background-dark: #202020;
/* Stitch Design Tokens */
--font-sans: var(--font-inter);
--font-heading: var(--font-manrope);
--shadow-level-1: 0px 4px 20px rgba(15, 23, 42, 0.05);
--shadow-level-2: 0px 10px 30px rgba(15, 23, 42, 0.08);
--font-heading: var(--font-memento-serif), ui-serif, Georgia, "Times New Roman", serif;
--shadow-level-1: 0 2px 4px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.06);
--shadow-level-2: 0 2px 8px rgba(0,0,0,0.06), 0 8px 16px rgba(0,0,0,0.08);
--shadow-level-3: 0 4px 8px rgba(0,0,0,0.04), 0 16px 32px rgba(0,0,0,0.12);
--shadow-acrylic: 0 8px 32px rgba(0,0,0,0.08);
--shadow-card-rest: 0 1px 2px rgba(0,0,0,0.06), 0 2px 4px rgba(0,0,0,0.04);
--shadow-card-hover: 0 2px 8px rgba(0,0,0,0.08), 0 8px 16px rgba(0,0,0,0.06);
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Custom scrollbar for better aesthetics */
/* Custom scrollbar for better aesthetics - Architectural Minimalist */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 3px;
height: 3px;
}
::-webkit-scrollbar-track {
@@ -35,16 +45,211 @@
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
background: rgba(28, 28, 28, 0.05);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
background: rgba(28, 28, 28, 0.15);
}
.dark ::-webkit-scrollbar-thumb {
background: #475569;
background: rgba(255, 255, 255, 0.08);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.18);
}
/* Win11 Acrylic / Mica blur utilities */
@utility acrylic {
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
}
@utility acrylic-heavy {
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
}
@utility acrylic-light {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(24px) saturate(1.35);
-webkit-backdrop-filter: blur(24px) saturate(1.35);
}
@utility font-memento-serif {
font-family: var(--font-memento-serif), ui-serif, Georgia, "Times New Roman", serif;
}
@utility acrylic-dark {
background: rgba(32, 32, 32, 0.75);
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
}
@utility win11-shadow {
box-shadow: var(--shadow-card-rest);
transition: box-shadow var(--transition-normal);
}
@utility win11-shadow-hover {
box-shadow: var(--shadow-card-hover);
}
/* Architectural Grid — texture & navigation (réf. architectural-grid1) */
.memento-paper-texture {
background-color: var(--background);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
background-size: auto;
}
html.dark .memento-paper-texture {
background-image: none;
}
.memento-sidebar-depth {
box-shadow: 1px 0 10px rgba(0, 0, 0, 0.05);
}
html.dark .memento-sidebar-depth {
box-shadow: 4px 0 24px -8px rgba(0, 0, 0, 0.4);
}
html:not(.dark) .memento-active-nav {
background: linear-gradient(to right, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.custom-scrollbar::-webkit-scrollbar {
width: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.08);
border-radius: 10px;
}
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.2);
}
.ai-glass {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.sidebar-shadow {
box-shadow: 1px 0 10px rgba(0, 0, 0, 0.05);
}
/* ──────────────────────────────────────────────
ARCHITECTURAL GRID — Design Tokens Aliases
Compatible with shadcn token system
────────────────────────────────────────────── */
/* CSS aliases: ink = foreground, paper = background */
:root {
--ink: var(--foreground);
--paper: var(--background);
--muted-ink: var(--muted-foreground);
--ai-accent: #75B2D6;
}
/* Sidebar toggle view button (Notebooks / Agents) */
.sidebar-view-toggle {
display: flex;
background: rgba(255, 255, 255, 0.5);
padding: 2px;
border-radius: 999px;
border: 1px solid var(--border);
}
.sidebar-view-toggle-btn {
padding: 6px;
border-radius: 999px;
transition: all 150ms;
color: var(--muted-foreground);
}
.sidebar-view-toggle-btn:hover {
color: var(--foreground);
}
.sidebar-view-toggle-btn.active {
background: var(--foreground);
color: var(--background);
}
/* Dark mode toggle */
html.dark .sidebar-view-toggle {
background: rgba(255, 255, 255, 0.08);
}
html.dark .sidebar-view-toggle-btn.active {
background: var(--primary);
color: var(--primary-foreground);
}
/* Inbox section separator */
.sidebar-inbox-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 12px;
transition: all 200ms;
width: 100%;
text-align: left;
}
.sidebar-inbox-item:hover {
background: rgba(255, 255, 255, 0.4);
}
.sidebar-inbox-item.active {
background: linear-gradient(to right, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
}
html.dark .sidebar-inbox-item:hover {
background: rgba(255, 255, 255, 0.06);
}
html.dark .sidebar-inbox-item.active {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.12);
}
/* Animated tab indicator for AI panel */
.ai-tab-indicator {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--foreground);
}
/* Note date in editorial view */
.note-date-badge {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
font-weight: 700;
color: var(--muted-foreground);
opacity: 0.7;
}
/* AI send button accent */
.ai-send-btn {
background: var(--ai-accent);
color: white;
border-radius: 8px;
padding: 8px;
transition: transform 100ms, opacity 100ms;
}
.ai-send-btn:hover {
opacity: 0.9;
transform: scale(1.05);
}
.ai-send-btn:active {
transform: scale(0.95);
}
/* Dark mode active nav */
html.dark .memento-active-nav {
background: rgba(255, 255, 255, 0.09);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Custom Prose overrides for compact notes */
@@ -105,133 +310,139 @@
:root {
--radius: 0.5rem;
--background: #f8fafc; /* Sub-surface off-white */
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
--card: oklch(1 0 0); /* Blanc pur */
--card-foreground: oklch(0.2 0.02 230);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 230);
--primary: #0284c7; /* Sky Blue */
--primary-foreground: #ffffff; /* Blanc */
--secondary: #e2e8f0; /* Gris-bleu très pâle */
--secondary-foreground: #1e293b;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: #f1f5f9;
--accent-foreground: #0284c7;
--destructive: oklch(0.6 0.18 25); /* Rouge */
--border: #cbd5e1; /* Gris-bleu visible */
--input: #cbd5e1; /* Bordure visible pour inputs/checkbox */
--ring: #0284c7;
--memento-desk: #E5E2D9;
--background: #F2F0E9;
--foreground: #1C1C1C;
--card: #faf9f5;
--card-foreground: #1C1C1C;
--popover: #faf9f5;
--popover-foreground: #1C1C1C;
--primary: #1C1C1C;
--primary-foreground: #F2F0E9;
--secondary: #e8e6df;
--secondary-foreground: #1C1C1C;
--muted: #e3e1d8;
--muted-foreground: rgba(28, 28, 28, 0.58);
--accent: #ebe9e2;
--accent-foreground: #1C1C1C;
--destructive: #c42b1c;
--border: rgba(28, 28, 28, 0.1);
--input: rgba(28, 28, 28, 0.12);
--ring: rgba(28, 28, 28, 0.35);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.97 0.004 230);
--sidebar-foreground: oklch(0.2 0.02 230);
--sidebar-primary: oklch(0.45 0.08 230);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.94 0.005 230);
--sidebar-accent-foreground: oklch(0.2 0.02 230);
--sidebar-border: oklch(0.9 0.008 230);
--sidebar-ring: oklch(0.7 0.005 230);
--sidebar: color-mix(in oklab, #ffffff 65%, #F2F0E9);
--sidebar-foreground: #1C1C1C;
--sidebar-primary: #1C1C1C;
--sidebar-primary-foreground: #F2F0E9;
--sidebar-accent: rgba(255, 255, 255, 0.45);
--sidebar-accent-foreground: #1C1C1C;
--sidebar-border: rgba(28, 28, 28, 0.1);
--sidebar-ring: rgba(28, 28, 28, 0.35);
}
html:not(.dark) {
--memento-sidebar-glow: color-mix(in oklab, #ffffff 72%, #F2F0E9 28%);
}
html.dark {
--memento-sidebar-glow: color-mix(in oklab, var(--primary) 22%, transparent);
}
[data-theme='light'] {
--background: oklch(0.985 0.003 230); /* Blanc grisâtre */
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
--card: oklch(1 0 0); /* Blanc pur */
--card-foreground: oklch(0.2 0.02 230);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 230);
--primary: oklch(0.45 0.08 230); /* Gris-bleu doux */
--primary-foreground: oklch(0.99 0 0); /* Blanc */
--secondary: oklch(0.94 0.005 230); /* Gris-bleu très pâle */
--secondary-foreground: oklch(0.2 0.02 230);
--muted: oklch(0.92 0.005 230);
--muted-foreground: oklch(0.6 0.01 230);
--accent: oklch(0.92 0.005 230);
--accent-foreground: oklch(0.2 0.02 230);
--destructive: oklch(0.6 0.18 25); /* Rouge */
--border: oklch(0.85 0.008 230); /* Gris-bleu visible */
--input: oklch(0.85 0.008 230); /* Bordure visible */
--ring: oklch(0.7 0.005 230);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 230);
--sidebar: oklch(0.97 0.004 230);
--sidebar-foreground: oklch(0.2 0.02 230);
--sidebar-primary: oklch(0.45 0.08 230);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.94 0.005 230);
--sidebar-accent-foreground: oklch(0.2 0.02 230);
--sidebar-border: oklch(0.85 0.008 230);
--sidebar-ring: oklch(0.7 0.005 230);
--memento-desk: #E5E2D9;
--background: #F2F0E9;
--foreground: #1C1C1C;
--card: #faf9f5;
--card-foreground: #1C1C1C;
--popover: #faf9f5;
--popover-foreground: #1C1C1C;
--primary: #1C1C1C;
--primary-foreground: #F2F0E9;
--secondary: #e8e6df;
--secondary-foreground: #1C1C1C;
--muted: #e3e1d8;
--muted-foreground: rgba(28, 28, 28, 0.58);
--accent: #ebe9e2;
--accent-foreground: #1C1C1C;
--destructive: #c42b1c;
--border: rgba(28, 28, 28, 0.1);
--input: rgba(28, 28, 28, 0.12);
--ring: rgba(28, 28, 28, 0.35);
--sidebar: color-mix(in oklab, #ffffff 65%, #F2F0E9);
--sidebar-foreground: #1C1C1C;
--sidebar-primary: #1C1C1C;
--sidebar-primary-foreground: #F2F0E9;
--sidebar-accent: rgba(255, 255, 255, 0.45);
--sidebar-accent-foreground: #1C1C1C;
--sidebar-border: rgba(28, 28, 28, 0.1);
--sidebar-ring: rgba(28, 28, 28, 0.35);
}
[data-theme='light'].dark {
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
--card-foreground: oklch(0.97 0.003 230);
--popover: oklch(0.18 0.006 230);
--popover-foreground: oklch(0.97 0.003 230);
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
--primary-foreground: oklch(0.1 0 0); /* Noir */
--secondary: oklch(0.24 0.006 230);
--secondary-foreground: oklch(0.97 0.003 230);
--muted: oklch(0.22 0.006 230);
--muted-foreground: oklch(0.55 0.01 230);
--accent: oklch(0.26 0.008 230);
--accent-foreground: oklch(0.97 0.003 230);
--background: oklch(0.16 0.006 75);
--foreground: oklch(0.96 0.004 85);
--card: oklch(0.2 0.008 75);
--card-foreground: oklch(0.96 0.004 85);
--popover: oklch(0.2 0.008 75);
--popover-foreground: oklch(0.96 0.004 85);
--primary: oklch(0.78 0.03 75);
--primary-foreground: oklch(0.18 0.02 60);
--secondary: oklch(0.26 0.008 75);
--secondary-foreground: oklch(0.96 0.004 85);
--muted: oklch(0.24 0.008 75);
--muted-foreground: oklch(0.62 0.012 75);
--accent: oklch(0.28 0.01 75);
--accent-foreground: oklch(0.96 0.004 85);
--destructive: oklch(0.65 0.18 25);
--border: oklch(0.33 0.01 230);
--input: oklch(0.33 0.01 230);
--ring: oklch(0.6 0.01 230);
--popover: oklch(0.18 0.006 230);
--popover-foreground: oklch(0.97 0.003 230);
--sidebar: oklch(0.12 0.005 230);
--sidebar-foreground: oklch(0.97 0.003 230);
--sidebar-primary: oklch(0.55 0.08 230);
--sidebar-primary-foreground: oklch(0.1 0 0);
--sidebar-accent: oklch(0.24 0.006 230);
--sidebar-accent-foreground: oklch(0.97 0.003 230);
--sidebar-border: oklch(0.28 0.01 230);
--sidebar-ring: oklch(0.6 0.01 230);
--border: oklch(0.33 0.012 75);
--input: oklch(0.33 0.012 75);
--ring: oklch(0.58 0.02 75);
--sidebar: oklch(0.14 0.006 75);
--sidebar-foreground: oklch(0.96 0.004 85);
--sidebar-primary: oklch(0.78 0.03 75);
--sidebar-primary-foreground: oklch(0.18 0.02 60);
--sidebar-accent: oklch(0.26 0.01 75);
--sidebar-accent-foreground: oklch(0.96 0.004 85);
--sidebar-border: oklch(0.3 0.012 75);
--sidebar-ring: oklch(0.58 0.02 75);
}
.dark {
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
--card-foreground: oklch(0.97 0.003 230);
--popover: oklch(0.18 0.006 230);
--popover-foreground: oklch(0.97 0.003 230);
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
--primary-foreground: oklch(0.1 0 0); /* Noir */
--secondary: oklch(0.24 0.006 230);
--secondary-foreground: oklch(0.97 0.003 230);
--muted: oklch(0.22 0.006 230);
--muted-foreground: oklch(0.55 0.01 230);
--accent: oklch(0.26 0.008 230);
--accent-foreground: oklch(0.97 0.003 230);
--destructive: oklch(0.65 0.18 25);
--border: oklch(0.33 0.01 230);
--input: oklch(0.33 0.01 230);
--ring: oklch(0.6 0.01 230);
--background: #202020;
--foreground: #ffffff;
--card: #2d2d2d;
--card-foreground: #ffffff;
--popover: #2d2d2d;
--popover-foreground: #ffffff;
--primary: #d6d3d1;
--primary-foreground: #1c1917;
--secondary: #2d2d2d;
--secondary-foreground: #ffffff;
--muted: #2d2d2d;
--muted-foreground: #9e9e9e;
--accent: #383838;
--accent-foreground: #ffffff;
--destructive: #ff6b6b;
--border: #3d3d3d;
--input: #3d3d3d;
--ring: #a8a29e;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.12 0.005 230);
--sidebar-foreground: oklch(0.97 0.003 230);
--sidebar-primary: oklch(0.55 0.08 230);
--sidebar-primary-foreground: oklch(0.1 0 0);
--sidebar-accent: oklch(0.24 0.006 230);
--sidebar-accent-foreground: oklch(0.97 0.003 230);
--sidebar-border: oklch(0.28 0.01 230);
--sidebar-ring: oklch(0.6 0.01 230);
--sidebar: rgba(32, 32, 32, 0.75);
--sidebar-foreground: #ffffff;
--sidebar-primary: #d6d3d1;
--sidebar-primary-foreground: #1c1917;
--sidebar-accent: #383838;
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #3d3d3d;
--sidebar-ring: #a8a29e;
}
[data-theme='midnight'] {
@@ -781,8 +992,12 @@ html.font-system * {
}
body {
@apply bg-background text-foreground;
/* Body inherits from html, can be adjusted per language */
@apply text-foreground;
background-color: var(--memento-desk);
}
html.dark body {
background-color: var(--background);
}
/* Latin languages use default (inherits from html) */
@@ -1073,7 +1288,7 @@ html.font-system * {
background: var(--popover);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);
box-shadow: 0 2px 8px rgba(0,0,0,0.08), 0 8px 16px rgba(0,0,0,0.06);
padding: 2px;
display: flex;
align-items: center;
@@ -1081,7 +1296,7 @@ html.font-system * {
z-index: 100;
}
.dark .notion-bubble-menu {
box-shadow: 0 4px 16px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06);
box-shadow: 0 2px 8px rgba(0,0,0,0.3), 0 8px 24px rgba(0,0,0,0.4);
}
.notion-bubble-btn {
@@ -1095,7 +1310,10 @@ html.font-system * {
background: transparent;
color: var(--foreground);
cursor: pointer;
transition: all 0.12s ease;
transition: all 0.15s ease;
}
.notion-bubble-btn:hover {
background: var(--accent);
}
.notion-bubble-btn:hover {
background: var(--accent);
@@ -1114,7 +1332,7 @@ html.font-system * {
background: var(--popover);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.08);
padding: 4px;
min-width: 150px;
z-index: 110;
@@ -1134,7 +1352,7 @@ html.font-system * {
cursor: pointer;
font-size: 0.8125rem;
color: var(--foreground);
transition: background 0.1s ease;
transition: background 0.15s ease;
}
.notion-ai-subitem:hover {
background: var(--accent);
@@ -1171,8 +1389,8 @@ html.font-system * {
.notion-ai-result-modal {
background: var(--popover);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 16px 48px rgba(0,0,0,0.12);
padding: 20px;
width: min(520px, 90vw);
max-height: 80vh;
@@ -1226,8 +1444,8 @@ html.font-system * {
.notion-image-modal {
background: var(--popover);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 16px 48px rgba(0,0,0,0.08);
padding: 20px;
width: 400px;
max-width: 90vw;
@@ -1239,15 +1457,16 @@ html.font-system * {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 4px;
background: var(--background);
color: var(--foreground);
font-size: 0.8125rem;
outline: none;
transition: border-color 0.15s ease;
transition: border-color var(--transition-fast);
}
.notion-modal-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
.notion-modal-input::placeholder {
color: var(--muted-foreground);
@@ -1255,13 +1474,13 @@ html.font-system * {
}
.notion-modal-btn {
padding: 6px 14px;
border-radius: 6px;
border-radius: 4px;
font-size: 0.8125rem;
border: 1px solid var(--border);
background: transparent;
color: var(--foreground);
cursor: pointer;
transition: background 0.1s ease;
transition: background var(--transition-fast);
}
.notion-modal-btn:hover {
background: var(--accent);
@@ -1284,22 +1503,22 @@ html.font-system * {
z-index: 9999;
background: var(--popover);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.08);
padding: 6px;
min-width: 320px;
max-height: 420px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
animation: slash-enter 0.12s ease;
animation: slash-enter 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes slash-enter {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.dark .notion-slash-menu {
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 2px 8px rgba(0,0,0,0.3);
box-shadow: 0 2px 8px rgba(0,0,0,0.3), 0 8px 32px rgba(0,0,0,0.5);
}
/* Header hint */
@@ -1331,14 +1550,14 @@ html.font-system * {
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 20px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
border: 1px solid var(--border);
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition: all 0.12s ease;
transition: all 0.15s ease;
white-space: nowrap;
}
.notion-slash-tab:hover {
@@ -1415,11 +1634,11 @@ html.font-system * {
padding: 6px 8px;
border: none;
background: transparent;
border-radius: 7px;
border-radius: 6px;
cursor: pointer;
text-align: left;
color: var(--foreground);
transition: background 0.1s ease, transform 0.08s ease;
transition: background 0.15s ease, transform 0.08s ease;
}
[dir="rtl"] .notion-slash-item {
text-align: right;
@@ -1439,11 +1658,11 @@ html.font-system * {
width: 30px;
height: 30px;
border: 1px solid var(--border);
border-radius: 7px;
border-radius: 6px;
background: var(--background);
color: var(--foreground);
flex-shrink: 0;
transition: background 0.1s ease;
transition: background 0.15s ease;
}
.notion-slash-item-selected .notion-slash-icon,
.notion-slash-item:hover .notion-slash-icon {
@@ -1518,4 +1737,44 @@ html.font-system * {
.rt-preview h3 { font-size: 1rem; font-weight: 600; margin: 0.3em 0; }
.rt-preview p { margin: 0.2em 0; }
.rt-preview ul, .rt-preview ol { padding-left: 1.25rem; margin: 0.2em 0; }
.rt-preview blockquote { border-left: 3px solid var(--border); padding-left: 0.75rem; color: var(--muted-foreground); margin: 0.3em 0; }
.rt-preview blockquote { border-left: 3px solid var(--border); padding-left: 0.75rem; color: var(--muted-foreground); margin: 0.3em 0; }
/* ── Full-page editorial editor: enforce prototype typography ──────────── */
/* TipTap ProseMirror text size — scoped to fullPage editor only */
.fullpage-editor .ProseMirror {
font-size: 1.125rem; /* 18px */
line-height: 1.875;
color: var(--foreground);
outline: none;
}
.fullpage-editor .ProseMirror p {
font-size: 1.125rem;
line-height: 1.875;
margin-bottom: 1.25em;
color: var(--foreground);
opacity: 0.85;
}
.fullpage-editor .ProseMirror h1 { font-size: 2rem; line-height: 1.3; font-family: var(--font-memento-serif, Georgia, serif); margin-bottom: 0.75em; }
.fullpage-editor .ProseMirror h2 { font-size: 1.5rem; line-height: 1.35; font-family: var(--font-memento-serif, Georgia, serif); margin-bottom: 0.6em; }
.fullpage-editor .ProseMirror h3 { font-size: 1.25rem; line-height: 1.4; margin-bottom: 0.5em; }
.fullpage-editor .ProseMirror ul,
.fullpage-editor .ProseMirror ol { font-size: 1.125rem; line-height: 1.8; padding-left: 1.5em; margin-bottom: 1em; }
.fullpage-editor .ProseMirror li { margin-bottom: 0.3em; }
.fullpage-editor .ProseMirror blockquote {
border-left: 3px solid var(--foreground);
padding-left: 1.25rem;
font-size: 1.2rem;
font-style: italic;
font-family: var(--font-memento-serif, Georgia, serif);
color: var(--foreground);
opacity: 0.7;
margin: 1.5em 0;
}
.fullpage-editor .ProseMirror p.is-editor-empty:first-child::before {
color: var(--muted-foreground);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
font-size: 1.125rem;
opacity: 0.4;
}

View File

@@ -9,8 +9,10 @@ import { DirectionInitializer } from "@/components/direction-initializer";
import { ErrorReporter } from "@/components/error-reporter";
import { auth } from "@/auth";
import Script from "next/script";
import { getThemeScript } from "@/lib/theme-script";
import { normalizeThemeId } from "@/lib/apply-document-theme";
import { Inter, Manrope } from "next/font/google";
import { Inter, Manrope, Playfair_Display } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
@@ -22,6 +24,12 @@ const manrope = Manrope({
variable: "--font-manrope",
});
const playfair = Playfair_Display({
subsets: ["latin"],
variable: "--font-memento-serif",
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "Memento - Your Digital Notepad",
description: "A beautiful note-taking app built with Next.js 16",
@@ -38,13 +46,18 @@ export const metadata: Metadata = {
};
export const viewport: Viewport = {
themeColor: "#3A7CA5",
themeColor: "#1C1C1C",
};
function getHtmlClass(theme?: string): string {
if (theme === 'dark') return 'dark';
if (theme === 'midnight') return 'dark';
return '';
function serverHtmlThemeState(theme?: string | null): { className?: string; dataTheme?: string } {
const t = normalizeThemeId(theme || 'light')
if (t === 'auto') return {}
if (t === 'dark') return { className: 'dark' }
if (t === 'light') return {}
if (t === 'midnight') return { className: 'dark', dataTheme: 'midnight' }
const named = ['sepia', 'rose', 'green', 'lavender', 'sand', 'ocean', 'sunset', 'blue'] as const
if ((named as readonly string[]).includes(t)) return { dataTheme: t }
return {}
}
/**
@@ -77,10 +90,21 @@ export default async function RootLayout({
getUserSettings(userId),
])
const htmlTheme = serverHtmlThemeState(userSettings.theme)
return (
<html suppressHydrationWarning className={getHtmlClass(userSettings.theme)}>
<html
suppressHydrationWarning
className={htmlTheme.className}
data-theme={htmlTheme.dataTheme}
>
<head />
<body className={`${inter.className} ${inter.variable} ${manrope.variable}`}>
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable}`}>
<Script
id="theme-early"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: getThemeScript(userSettings.theme) }}
/>
<Script
id="sw-cleanup"
strategy="afterInteractive"

View File

@@ -21,6 +21,7 @@ import {
XCircle,
Clock,
Pencil,
Presentation,
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
@@ -51,11 +52,17 @@ interface AgentCardProps {
// --- Config ---
/** Icône par type — tons neutres alignés sur le thème (encre / papier). */
const ICON_BOX = 'bg-primary/10 dark:bg-primary/15'
const ICON_MARK = 'text-primary'
const typeConfig: Record<string, { icon: typeof Globe; color: string; bgColor: string }> = {
scraper: { icon: Globe, color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-50 dark:bg-blue-950' },
researcher: { icon: Search, color: 'text-violet-600 dark:text-violet-400', bgColor: 'bg-violet-50 dark:bg-violet-950' },
monitor: { icon: Eye, color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-50 dark:bg-amber-950' },
custom: { icon: Settings, color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-50 dark:bg-emerald-950' },
scraper: { icon: Globe, color: ICON_MARK, bgColor: ICON_BOX },
researcher: { icon: Search, color: ICON_MARK, bgColor: ICON_BOX },
monitor: { icon: Eye, color: ICON_MARK, bgColor: ICON_BOX },
custom: { icon: Settings, color: ICON_MARK, bgColor: ICON_BOX },
'slide-generator': { icon: Presentation, color: ICON_MARK, bgColor: ICON_BOX },
'excalidraw-generator': { icon: Pencil, color: ICON_MARK, bgColor: ICON_BOX },
}
const frequencyKeys: Record<string, string> = {
@@ -177,7 +184,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<div className={`
font-display group flex flex-col bg-card rounded-lg border transition-all duration-200
${agent.isEnabled
? 'border-border/40 hover:border-primary/30 hover:shadow-[0_2px_12px_rgba(0,91,193,0.08)]'
? 'border-border/40 hover:border-primary/25 hover:shadow-[0_2px_12px_color-mix(in_oklab,var(--foreground)_7%,transparent)]'
: 'border-border/30 opacity-60'
}
`}>
@@ -194,12 +201,12 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<div className="flex items-center gap-1.5">
<h3 className="font-semibold text-sm text-card-foreground truncate leading-tight">{agent.name}</h3>
{mounted && isNew && (
<span className="flex-shrink-0 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 rounded">
<span className="flex-shrink-0 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider bg-muted text-muted-foreground rounded border border-border/60">
{t('agents.newBadge')}
</span>
)}
</div>
<span className={`text-[11px] font-bold uppercase tracking-wider ${config.color}`}>
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
{t(`agents.types.${agent.type || 'custom'}`)}
</span>
</div>
@@ -263,8 +270,8 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<p className="text-[10px] text-muted-foreground font-medium mb-0.5">{t('agents.status.lastStatus')}</p>
{lastAction ? (
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
lastAction.status === 'success' ? 'text-emerald-600 dark:text-emerald-400' :
lastAction.status === 'failure' ? 'text-red-600 dark:text-red-400' :
lastAction.status === 'success' ? 'text-primary' :
lastAction.status === 'failure' ? 'text-destructive' :
lastAction.status === 'running' ? 'text-primary' :
'text-muted-foreground'
}`}>
@@ -302,7 +309,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<button
onClick={handleDelete}
disabled={isDeleting}
className="p-1.5 text-red-500 bg-red-50 dark:bg-red-950/20 rounded-md hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors disabled:opacity-40"
className="p-1.5 text-destructive bg-destructive/10 rounded-md hover:bg-destructive/20 transition-colors disabled:opacity-40"
title={t('agents.actions.delete')}
>
{isDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}

View File

@@ -358,7 +358,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<button
type="button"
onClick={() => removeUrl(i)}
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950 rounded-lg transition-colors"
className="p-2 text-destructive/80 hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
@@ -721,7 +721,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{t(at.labelKey)}</span>
{at.external && !isSelected && (
<span className="ml-auto text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950 px-1.5 py-0.5 rounded-full">{t('agents.tools.configNeeded')}</span>
<span className="ml-auto text-[10px] text-muted-foreground bg-muted border border-border/60 px-1.5 py-0.5 rounded-full">{t('agents.tools.configNeeded')}</span>
)}
</button>
)

View File

@@ -32,16 +32,16 @@ export function AgentHelp({ onClose }: AgentHelpProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[85vh] flex flex-col mx-4">
<div className="bg-card rounded-2xl shadow-2xl w-full max-w-3xl max-h-[85vh] flex flex-col mx-4 border border-border">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div className="flex items-center gap-2.5">
<LifeBuoy className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t('agents.help.title')}</h2>
<h2 className="text-lg font-semibold text-foreground">{t('agents.help.title')}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-5 h-5" />
</button>
@@ -53,25 +53,25 @@ export function AgentHelp({ onClose }: AgentHelpProps) {
<details
key={section.key}
open={section.defaultOpen}
className="group border-b border-slate-100 dark:border-slate-800 last:border-b-0"
className="group border-b border-border/60 last:border-b-0"
>
<summary className="flex items-center gap-2 cursor-pointer py-3 font-medium text-slate-800 dark:text-slate-200 select-none hover:text-primary transition-colors text-sm">
<summary className="flex items-center gap-2 cursor-pointer py-3 font-medium text-foreground select-none hover:text-primary transition-colors text-sm">
<span className="text-primary text-xs transition-transform group-open:rotate-90">&#9656;</span>
{t(`agents.help.${section.key}`)}
</summary>
<div className="pb-4 pl-5 prose prose-slate dark:prose-invert prose-sm max-w-none
prose-headings:font-semibold prose-headings:text-slate-800 dark:prose-headings:text-slate-200
<div className="pb-4 pl-5 prose prose-sm max-w-none dark:prose-invert
prose-headings:font-semibold prose-headings:text-foreground
prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1
prose-p:leading-relaxed prose-p:text-slate-600 dark:prose-p:text-slate-400 prose-p:my-1.5
prose-li:text-slate-600 dark:prose-li:text-slate-400 prose-li:my-0.5
prose-strong:text-slate-700 dark:prose-strong:text-slate-300
prose-p:leading-relaxed prose-p:text-muted-foreground prose-p:my-1.5
prose-li:text-muted-foreground prose-li:my-0.5
prose-strong:text-foreground
prose-code:text-primary prose-code:bg-primary/5 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
prose-ul:my-2 prose-ol:my-2
prose-hr:border-slate-200 dark:prose-hr:border-slate-700
prose-hr:border-border
prose-table:text-xs
prose-th:text-left prose-th:font-medium prose-th:text-slate-700 dark:prose-th:text-slate-300 prose-th:py-1 prose-th:pr-3
prose-td:text-slate-600 dark:prose-td:text-slate-400 prose-td:py-1 prose-td:pr-3
prose-blockquote:border-primary/30 prose-blockquote:text-slate-500 dark:prose-blockquote:text-slate-400
prose-th:text-left prose-th:font-medium prose-th:text-foreground prose-th:py-1 prose-th:pr-3
prose-td:text-muted-foreground prose-td:py-1 prose-td:pr-3
prose-blockquote:border-primary/30 prose-blockquote:text-muted-foreground
">
<Markdown remarkPlugins={[remarkGfm]}>
{t(`agents.help.${section.key}Content`)}
@@ -82,10 +82,10 @@ export function AgentHelp({ onClose }: AgentHelpProps) {
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 shrink-0">
<div className="px-6 py-4 border-t border-border shrink-0">
<button
onClick={onClose}
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
className="w-full px-4 py-2.5 text-sm font-medium text-primary-foreground bg-primary rounded-lg hover:bg-primary/90 transition-colors"
>
{t('agents.help.close')}
</button>

View File

@@ -106,17 +106,17 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
key={action.id}
className={`
p-3 rounded-lg border
${action.status === 'success' ? 'bg-green-50/50 dark:bg-green-950/50 border-green-100 dark:border-green-900' : ''}
${action.status === 'failure' ? 'bg-red-50/50 dark:bg-red-950/50 border-red-100 dark:border-red-900' : ''}
${action.status === 'running' ? 'bg-blue-50/50 dark:bg-blue-950/50 border-blue-100 dark:border-blue-900' : ''}
${action.status === 'success' ? 'bg-muted/40 border-border' : ''}
${action.status === 'failure' ? 'bg-destructive/5 border-destructive/25' : ''}
${action.status === 'running' ? 'bg-primary/5 border-primary/25' : ''}
${action.status === 'pending' ? 'bg-muted border-border' : ''}
`}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{action.status === 'success' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
{action.status === 'failure' && <XCircle className="w-4 h-4 text-red-500" />}
{action.status === 'running' && <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />}
{action.status === 'success' && <CheckCircle2 className="w-4 h-4 text-primary" />}
{action.status === 'failure' && <XCircle className="w-4 h-4 text-destructive" />}
{action.status === 'running' && <Loader2 className="w-4 h-4 text-primary animate-spin" />}
{action.status === 'pending' && <Clock className="w-4 h-4 text-muted-foreground" />}
</div>
<div className="flex-1 min-w-0">

View File

@@ -13,6 +13,8 @@ import {
Settings,
Plus,
Loader2,
Presentation,
Pencil,
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
@@ -43,6 +45,8 @@ const templateConfig = [
], frequency: 'weekly' },
{ id: 'surveillant', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', urls: [], frequency: 'weekly' },
{ id: 'chercheur', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', urls: [], frequency: 'manual' },
{ id: 'slideGenerator', type: 'slide-generator', roleKey: 'agents.defaultRoles.slideGenerator', urls: [], frequency: 'manual' },
{ id: 'excalidrawGenerator', type: 'excalidraw-generator', roleKey: 'agents.defaultRoles.excalidrawGenerator', urls: [], frequency: 'manual' },
] as const
const typeIcons: Record<string, typeof Globe> = {
@@ -50,14 +54,11 @@ const typeIcons: Record<string, typeof Globe> = {
researcher: Search,
monitor: Eye,
custom: Settings,
'slide-generator': Presentation,
'excalidraw-generator': Pencil,
}
const typeColors: Record<string, string> = {
scraper: 'text-blue-600 bg-blue-50',
researcher: 'text-purple-600 bg-purple-50',
monitor: 'text-amber-600 bg-amber-50',
custom: 'text-green-600 bg-green-50',
}
const templateIconBox = 'bg-primary/10 text-primary dark:bg-primary/15'
export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) {
const { t } = useLanguage()
@@ -89,7 +90,11 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
? ['web_search', 'web_scrape', 'note_search', 'note_create']
: tpl.type === 'monitor'
? ['note_search', 'note_read', 'note_create']
: [],
: tpl.type === 'slide-generator'
? ['note_search', 'note_read', 'generate_pptx']
: tpl.type === 'excalidraw-generator'
? ['note_search', 'note_read', 'generate_excalidraw']
: [],
})
toast.success(t('agents.toasts.installSuccess', { name: resolvedName }))
onInstalled()
@@ -115,10 +120,10 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
return (
<div
key={tpl.id}
className="border-2 border-dashed border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-primary/30 hover:bg-primary/[0.02] transition-all group"
className="border-2 border-dashed border-border/70 rounded-xl p-4 hover:border-primary/35 hover:bg-primary/[0.03] transition-all group"
>
<div className="flex items-center gap-2.5 mb-2">
<div className={`p-1.5 rounded-lg ${typeColors[tpl.type]}`}>
<div className={`p-1.5 rounded-lg ${templateIconBox}`}>
<Icon className="w-4 h-4" />
</div>
<h4 className="font-medium text-sm text-foreground">{t(nameKey)}</h4>

View File

@@ -0,0 +1,12 @@
'use client'
import { AIChat } from '@/components/ai-chat'
/**
* Always render AIChat — on desktop the floating trigger button is shown
* unless a note editor already has the contextual panel open.
* The sidebar dispatches 'toggle-ai-chat' event to open it programmatically.
*/
export function AIChatLayoutBridge() {
return <AIChat showFloatingTrigger={true} />
}

View File

@@ -30,7 +30,7 @@ const TONES = [
{ id: 'casual', label: 'Casual', icon: Coffee },
]
export function AIChat() {
export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: boolean } = {}) {
const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable()
const { notebooks } = useNotebooks()
@@ -143,6 +143,7 @@ export function AIChat() {
if (!isOpen) {
if (isContextualAIVisible) return null
if (!showFloatingTrigger) return null
return (
<Button
onClick={() => setIsOpen(true)}

View File

@@ -13,8 +13,9 @@ import {
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
GitMerge, PlusCircle, Eye, Code, Languages,
Presentation, PenTool, ExternalLink,
Presentation, PenTool, ExternalLink, ImagePlus,
} from 'lucide-react'
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
import { toast } from 'sonner'
@@ -94,6 +95,10 @@ interface ContextualAIChatProps {
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 ─────────────────────────────────────────────────────────────────
@@ -109,6 +114,8 @@ export function ContextualAIChat({
lastActionApplied = false,
notebooks = [],
className,
diagramInsertFormat = 'markdown',
onGenerateTitle,
}: ContextualAIChatProps) {
const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable()
@@ -135,6 +142,7 @@ export function ContextualAIChat({
const [slideStyle, setSlideStyle] = useState('soft')
const [diagramType, setDiagramType] = useState('auto')
const [diagramStyle, setDiagramStyle] = useState('default')
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
// Resource tab state
const [resourceUrl, setResourceUrl] = useState('')
@@ -329,9 +337,8 @@ export function ContextualAIChat({
})
} else if (type === 'diagram' && poll.canvasId) {
toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', {
id: toastId, duration: 10000,
description: t('ai.generate.toastSuccessDiagram') || 'Votre diagramme est disponible dans le Lab.',
action: { label: t('ai.generate.openDiagram') || 'Ouvrir', onClick: () => { window.location.href = `/lab?id=${poll.canvasId}` } },
id: toastId, duration: 8000,
description: t('ai.generate.diagramReadyHint') || '',
})
} else {
toast.success(type === 'slides'
@@ -365,6 +372,53 @@ export function ContextualAIChat({
}
}, [])
const buildDiagramImageSnippet = (imageUrl: string, alt: string) => {
if (diagramInsertFormat === 'html') {
const safeAlt = alt.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
return `\n<p><img src="${imageUrl}" alt="${safeAlt}" loading="lazy" style="max-width:100%;height:auto;border-radius:8px;" /></p>\n`
}
const safeMdAlt = alt.replace(/[\[\]]/g, '')
return `\n\n![${safeMdAlt}](${imageUrl})\n\n`
}
const handleEmbedDiagramInNote = async (canvasId: string) => {
if (!onApplyToNote) {
toast.error(t('ai.generate.insertNeedEditor'))
return
}
setDiagramEmbedLoading(true)
try {
const res = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`)
const data = await res.json()
if (!res.ok || !data.canvas?.data) {
toast.error(data.error || t('ai.generate.insertFetchError'))
return
}
const blob = await exportExcalidrawSceneToPngBlob(data.canvas.data)
if (!blob) {
toast.error(t('ai.generate.insertExportError'))
return
}
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) {
toast.error(upJson.error || t('ai.generate.insertUploadError'))
return
}
const alt = t('ai.generate.diagramImageAlt')
const snippet = buildDiagramImageSnippet(upJson.url as string, alt)
onApplyToNote(`${noteContent ?? ''}${snippet}`)
toast.success(t('ai.generate.insertedInNote'))
} catch (e) {
console.error('[embed diagram]', e)
toast.error(t('ai.generate.insertExportError'))
} finally {
setDiagramEmbedLoading(false)
}
}
// ── Resource tab handlers ────────────────────────────────────────────────────
const handleScrapeUrl = async () => {
@@ -461,74 +515,99 @@ export function ContextualAIChat({
return (
<aside className={cn(
'border-l border-border/40 bg-card flex flex-col self-stretch flex-shrink-0 z-10 transition-all duration-300',
expanded ? 'w-[560px]' : 'w-[360px]',
className,
)}>
<>
{/* Backdrop when expanded */}
{expanded && (
<div
className="fixed inset-0 z-[199] bg-black/10 dark:bg-black/20 backdrop-blur-[1px]"
onClick={() => setExpanded(false)}
/>
)}
<aside className={cn(
'border-l border-border/40 bg-background flex flex-col flex-shrink-0 z-10 transition-all duration-300',
expanded
? 'fixed right-0 top-0 h-screen w-[640px] z-[200] shadow-2xl border-l'
: 'h-full w-[360px]',
!expanded && className,
)}>
{/* ── Header ───────────────────────────────────────────────── */}
<div className="px-4 py-3 border-b border-border/40 flex items-center justify-between shrink-0">
<div className="min-w-0">
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary shrink-0" />
{t('ai.aiNoteTitle')}
</h2>
<p className="text-xs text-muted-foreground truncate">
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
{lastActionApplied && onUndoLastAction && (
{/* ── Header ─────────────────────────────────────────── */}
<div className="px-5 pt-5 pb-4 border-b border-border/40 shrink-0">
<div className="flex items-start justify-between">
<div className="min-w-0">
<h2 className="font-memento-serif text-xl font-medium text-foreground flex items-center gap-2 leading-tight">
<Sparkles className="h-4 w-4 text-amber-500 shrink-0" />
{t('ai.aiNoteTitle')}
</h2>
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.15em] mt-1 truncate font-medium">
{noteTitle ? noteTitle : t('ai.currentNote')}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0 -mt-1">
{lastActionApplied && onUndoLastAction && (
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/30"
onClick={onUndoLastAction}
title={t('ai.undoLastAction')}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
{onGenerateTitle && (
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-muted-foreground hover:text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-950/30"
onClick={onGenerateTitle}
title="Générer un titre automatique"
>
<Wand2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/30"
onClick={onUndoLastAction}
title={t('ai.undoLastAction')}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setExpanded(e => !e)}
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
>
<RotateCcw className="h-3.5 w-3.5" />
{expanded
? <Minimize2 className="h-3.5 w-3.5" />
: <Maximize2 className="h-3.5 w-3.5" />}
</Button>
)}
{/* Expand / Shrink */}
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setExpanded(e => !e)}
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
>
{expanded
? <Minimize2 className="h-3.5 w-3.5" />
: <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7 text-muted-foreground hover:text-foreground">
<X className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7 text-muted-foreground hover:text-foreground">
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
{/* ── Tabs ─────────────────────────────────────────────────── */}
{/* ── Tabs ── */}
<div className="flex border-b border-border/40 shrink-0">
{([
{ id: 'chat', icon: Bot, label: t('ai.chatTab') },
{ id: 'actions', icon: Wand2, label: t('ai.noteActions') },
{ id: 'resource', icon: ArrowDownToLine, label: t('ai.resourceTab') },
{ id: 'chat', icon: Bot, label: 'Chat' },
{ id: 'actions', icon: Wand2, label: 'Actions' },
{ id: 'resource', icon: ArrowDownToLine, label: 'Source' },
] as const).map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={cn(
'flex-1 py-2.5 border-b-2 text-xs font-semibold flex items-center justify-center gap-1.5 transition-all',
'relative flex-1 py-2.5 text-[11px] font-semibold uppercase tracking-wider flex items-center justify-center gap-1.5 transition-all whitespace-nowrap',
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground',
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
<tab.icon className="h-3 w-3" />
<tab.icon className="h-3.5 w-3.5 shrink-0" />
{tab.label}
{activeTab === tab.id && (
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground" />
)}
</button>
))}
</div>
{/* ══════════════════════════════════════════════════════════ */}
{/* ── TAB: CHAT ─────────────────────────────────────────── */}
{/* ══════════════════════════════════════════════════════════ */}
@@ -537,11 +616,11 @@ export function ContextualAIChat({
{/* Messages */}
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3 flex flex-col">
{messages.length === 0 && (
<div className="flex-1 flex flex-col items-center justify-center text-center opacity-60">
<div className="w-14 h-14 rounded-full bg-primary/5 flex items-center justify-center border border-primary/10 mb-4">
<Sparkles className="h-6 w-6 text-primary/50" />
<div className="flex-1 flex flex-col items-center justify-center gap-3 py-8">
<div className="w-10 h-10 rounded-full bg-amber-50 dark:bg-amber-950/30 border border-amber-200/50 flex items-center justify-center">
<Sparkles className="h-5 w-5 text-amber-500" />
</div>
<p className="text-sm text-muted-foreground max-w-[220px]">
<p className="text-[13px] text-muted-foreground text-center max-w-[200px] leading-relaxed">
{t('ai.askToStart')}
</p>
</div>
@@ -769,7 +848,7 @@ export function ContextualAIChat({
{/* Tone selector */}
<div className="px-3 py-2 flex flex-col gap-1.5">
<label className="text-xs font-bold tracking-wider text-muted-foreground uppercase">
<label className="text-[10px] font-bold tracking-wider text-muted-foreground uppercase">
{t('ai.writingTone')}
</label>
<div className="grid grid-cols-4 gap-1">
@@ -782,13 +861,13 @@ export function ContextualAIChat({
onClick={() => setSelectedTone(tone.id)}
title={tone.full}
className={cn(
'py-1.5 rounded border text-xs font-medium transition-all flex flex-col items-center gap-1',
'py-2 rounded-lg border text-[11px] font-semibold transition-all flex flex-col items-center gap-1',
sel
? 'border-primary bg-primary/10 text-primary'
: 'border-border/40 text-muted-foreground hover:bg-muted bg-card',
? 'border-foreground bg-foreground text-background'
: 'border-border/60 text-muted-foreground hover:border-foreground/40 hover:text-foreground bg-background',
)}
>
<Icon className="h-3 w-3" />
<Icon className="h-4 w-4" />
{tone.label}
</button>
)
@@ -1089,11 +1168,24 @@ export function ContextualAIChat({
{/* Diagram result */}
{generateResult?.type === 'diagram' && generateResult.canvasId && (
<a href={`/lab?id=${generateResult.canvasId}`}
className="mt-2 w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all">
<ExternalLink className="h-4 w-4 shrink-0" />
{t('ai.generate.openDiagram') || 'Ouvrir dans le Lab'}
</a>
<div className="mt-2 flex flex-col gap-2">
<a href={`/lab?id=${generateResult.canvasId}`}
className="w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all">
<PenTool className="h-4 w-4 shrink-0" />
{t('ai.generate.openInExcalidraw')}
</a>
<button
type="button"
disabled={diagramEmbedLoading}
onClick={() => void handleEmbedDiagramInNote(generateResult.canvasId!)}
className="w-full flex items-center gap-2 rounded-xl border border-cyan-200 bg-cyan-50 dark:border-cyan-800 dark:bg-cyan-950/30 px-4 py-2.5 text-sm font-medium text-cyan-700 dark:text-cyan-300 hover:bg-cyan-100 dark:hover:bg-cyan-900/40 transition-all disabled:opacity-60 text-left"
>
{diagramEmbedLoading
? <Loader2 className="h-4 w-4 shrink-0 animate-spin" />
: <ImagePlus className="h-4 w-4 shrink-0" />}
{t('ai.generate.insertDiagramInNote')}
</button>
</div>
)}
</div>
)}
@@ -1304,6 +1396,7 @@ export function ContextualAIChat({
</div>
)}
</aside>
</>
)
}

View File

@@ -40,7 +40,7 @@ export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading
}
return (
<section data-testid="favorites-section" className="mb-8">
<section data-testid="favorites-section" id="memento-pinned" className="mb-8 scroll-mt-28">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
onKeyDown={(e) => {
@@ -55,7 +55,7 @@ export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading
>
<div className="flex items-center gap-2">
<span className="text-2xl">📌</span>
<h2 className="text-lg font-semibold text-foreground">
<h2 className="font-memento-serif text-xl font-normal tracking-tight text-foreground md:text-2xl">
{t('notes.pinnedNotes')}
<span className="text-sm font-medium text-muted-foreground ml-2">
({pinnedNotes.length})

View File

@@ -3,7 +3,7 @@
import { Suspense } from 'react'
import { Header } from './header'
import { useSearchParams, useRouter } from 'next/navigation'
import { useLabels } from '@/context/LabelContext'
import { useNotebooks } from '@/context/notebooks-context'
interface HeaderWrapperProps {
onColorFilterChange?: (color: string | null) => void
@@ -13,7 +13,7 @@ interface HeaderWrapperProps {
function HeaderContent({ onColorFilterChange, user }: HeaderWrapperProps) {
const searchParams = useSearchParams()
const router = useRouter()
const { labels } = useLabels()
const { labels } = useNotebooks()
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
const selectedColor = searchParams.get('color') || null

View File

@@ -21,10 +21,11 @@ import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Sparkles, Grid3x3, S
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { useNotebooks } from '@/context/notebooks-context'
import { LabelFilter } from './label-filter'
import { NotificationPanel } from './notification-panel'
import { updateTheme } from '@/app/actions/profile'
import { applyDocumentTheme, normalizeThemeId } from '@/lib/apply-document-theme'
import { useDebounce } from '@/hooks/use-debounce'
import { useLanguage } from '@/lib/i18n'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
@@ -46,13 +47,13 @@ export function Header({
user
}: HeaderProps = {}) {
const [searchQuery, setSearchQuery] = useState('')
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const [theme, setTheme] = useState<string>('light')
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [isSemanticSearching, setIsSemanticSearching] = useState(false)
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const { labels, setNotebookId } = useLabels()
const { labels, setNotebookId } = useNotebooks()
const { t } = useLanguage()
const { data: session } = useSession()
@@ -167,24 +168,13 @@ export function Header({
}, [currentUser])
const applyTheme = async (newTheme: string, persist = true) => {
setTheme(newTheme as any)
localStorage.setItem('theme-preference', newTheme)
// Remove all theme classes first
document.documentElement.classList.remove('dark')
document.documentElement.removeAttribute('data-theme')
if (newTheme === 'dark') {
document.documentElement.classList.add('dark')
} else if (newTheme !== 'light') {
document.documentElement.setAttribute('data-theme', newTheme)
if (newTheme === 'midnight') {
document.documentElement.classList.add('dark')
}
}
const normalized = normalizeThemeId(newTheme)
setTheme(normalized)
localStorage.setItem('theme-preference', normalized)
applyDocumentTheme(normalized)
if (persist && currentUser) {
await updateTheme(newTheme)
await updateTheme(normalized)
}
}
@@ -265,10 +255,10 @@ export function Header({
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2 text-left",
"w-full flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200 mr-2 text-left",
active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
: "hover:bg-gray-100 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300"
)}
style={{ minHeight: '44px' }}
aria-pressed={active}
@@ -283,10 +273,10 @@ export function Header({
href={href}
onClick={() => setIsSidebarOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
"flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200 mr-2",
active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
: "hover:bg-gray-100 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300"
)}
style={{ minHeight: '44px' }}
aria-current={active ? 'page' : undefined}
@@ -301,26 +291,27 @@ export function Header({
return (
<>
{/* Top Navigation - Style Keep */}
<header className="flex-none flex items-center justify-between whitespace-nowrap border-b border-solid border-slate-200 dark:border-slate-800 bg-white dark:bg-[#1e2128] px-6 py-3 z-20">
<div className="flex items-center gap-8">
<header className="flex-none flex items-center justify-between whitespace-nowrap border-b border-border/50 bg-card/85 backdrop-blur-xl px-5 py-3 z-20 shadow-sm md:px-8">
<div className="flex items-center gap-6 md:gap-10">
{/* Logo MEMENTO */}
<div className="flex items-center gap-3 text-slate-900 dark:text-white cursor-pointer group" onClick={() => router.push('/')}>
<div className="size-8 bg-primary rounded-lg flex items-center justify-center text-primary-foreground shadow-sm group-hover:shadow-md transition-all">
<StickyNote className="w-5 h-5" />
{/* Wordmark */}
<div className="flex cursor-pointer items-center gap-3 text-foreground group" onClick={() => router.push('/')}>
<div className="flex size-9 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-md shadow-primary/20 transition-all duration-200 group-hover:shadow-lg group-hover:shadow-primary/25">
<StickyNote className="w-[1.15rem] h-[1.15rem]" strokeWidth={2} />
</div>
<h2 className="text-xl font-bold leading-tight tracking-tight">MEMENTO</h2>
<h2 className="font-memento-serif text-[1.35rem] font-normal tracking-tight leading-none md:text-2xl">Memento</h2>
</div>
{/* Search Bar */}
<label className="hidden md:flex flex-col min-w-40 w-96 !h-10">
<div className="flex w-full flex-1 items-stretch rounded-full h-full bg-slate-100 dark:bg-slate-800 border border-transparent focus-within:bg-white dark:focus-within:bg-slate-700 focus-within:border-primary/30 focus-within:shadow-md transition-all duration-200">
<div className="text-slate-400 dark:text-slate-400 flex items-center justify-center pl-4 focus-within:text-primary transition-colors">
<div className="flex w-full flex-1 items-stretch rounded-xl h-full bg-muted/50 border border-transparent focus-within:bg-card focus-within:border-primary/35 focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_18%,transparent)] transition-all duration-200">
<div className="text-muted-foreground flex items-center justify-center pl-4 focus-within:text-primary transition-colors">
<Search className="w-4 h-4" />
</div>
<input
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 px-3 text-sm focus:ring-0 focus:outline-none"
id="memento-global-search"
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-foreground placeholder:text-muted-foreground px-3 text-sm focus:ring-0 focus:outline-none"
placeholder={t('search.placeholder') }
type="text"
value={searchQuery}
@@ -333,15 +324,15 @@ export function Header({
<div className="flex flex-1 justify-end gap-2 items-center">
{/* Quick nav: Notes (hidden-sidebar only), Chat, Agents, Lab */}
<div className="hidden md:flex items-center gap-1 bg-slate-100 dark:bg-slate-800/60 rounded-full px-1.5 py-1">
<div className="hidden md:flex items-center gap-1 rounded-xl bg-muted/60 px-1.5 py-1 ring-1 ring-border/50">
{noSidebarMode && (
<Link
href="/"
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
pathname === '/'
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
)}
>
<StickyNote className="h-3.5 w-3.5" />
@@ -351,10 +342,10 @@ export function Header({
<Link
href="/agents"
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
pathname === '/agents'
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
)}
>
<Bot className="h-3.5 w-3.5" />
@@ -363,10 +354,10 @@ export function Header({
<Link
href="/lab"
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
pathname === '/lab'
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
pathname === '/lab'
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
)}
>
<FlaskConical className="h-3.5 w-3.5" />
@@ -380,7 +371,7 @@ export function Header({
{/* Settings Button */}
<Link
href="/settings"
className="flex items-center justify-center size-10 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 transition-colors"
className="flex items-center justify-center size-10 rounded-xl text-muted-foreground hover:bg-muted/80 hover:text-foreground transition-colors duration-200"
>
<Settings className="w-5 h-5" />
</Link>
@@ -388,7 +379,7 @@ export function Header({
{/* User Avatar Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center justify-center bg-center bg-no-repeat bg-cover rounded-full size-10 ring-2 ring-white dark:ring-slate-700 cursor-pointer shadow-sm hover:shadow-md transition-shadow bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground"
<div className="flex items-center justify-center bg-center bg-no-repeat bg-cover rounded-xl size-10 ring-2 ring-border/60 cursor-pointer shadow-sm hover:shadow-md transition-shadow duration-200 bg-primary/10 text-primary"
style={currentUser?.image ? { backgroundImage: `url(${currentUser?.image})` } : undefined}>
{!currentUser?.image && (
<span className="text-sm font-semibold">

View File

@@ -1,31 +1,31 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef, useTransition, useMemo } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote } from '@/app/actions/notes'
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
import { NotesViewToggle } from '@/components/notes-view-toggle'
import { NotesEditorialView } from '@/components/notes-editorial-view'
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { FavoritesSection } from '@/components/favorites-section'
import { Button } from '@/components/ui/button'
import { Wand2, ChevronRight, Plus, FileText } from 'lucide-react'
import { useLabels } from '@/context/LabelContext'
import { Plus, ArrowUpDown } from 'lucide-react'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useRefresh } from '@/lib/use-refresh'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
import { useNotebooks } from '@/context/notebooks-context'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { cn } from '@/lib/utils'
import { LabelFilter } from '@/components/label-filter'
import { useLanguage } from '@/lib/i18n'
import { useHomeView } from '@/context/home-view-context'
import { useEditorUI } from '@/context/editor-ui-context'
import { NoteHistoryModal } from '@/components/note-history-modal'
import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react'
type SortOrder = 'newest' | 'oldest' | 'alpha'
// Lazy-load heavy dialogs — uniquement chargés à la demande
const NoteEditor = dynamic(
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
{ ssr: false }
@@ -41,7 +41,7 @@ const AutoLabelSuggestionDialog = dynamic(
type InitialSettings = {
showRecentNotes: boolean
notesViewMode: 'masonry' | 'tabs'
notesViewMode: 'masonry' | 'tabs' | 'list'
noteHistory: boolean
noteHistoryMode: 'manual' | 'auto'
}
@@ -63,14 +63,20 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode)
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
const [isLoading, setIsLoading] = useState(false)
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [historyNote, setHistoryNote] = useState<Note | null>(null)
const [isCreating, startCreating] = useTransition()
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
const [showSortMenu, setShowSortMenu] = useState(false)
const notesRef = useRef(notes)
notesRef.current = notes
const { refreshKey, triggerRefresh } = useNoteRefresh()
const { labels } = useLabels()
const { setControls } = useHomeView()
const { refreshNotes } = useRefresh()
const { labels, notebooks } = useNotebooks()
const { setControls } = useEditorUI()
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
@@ -81,9 +87,19 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}
}, [shouldSuggestLabels, suggestNotebookId])
const notebookFilter = searchParams.get('notebook')
const isInbox = !notebookFilter
// Sidebar carnet / inbox: forceList → liste éditoriale + fermer l'éditeur plein écran (comme la ref. architectural-grid)
useEffect(() => {
const forceList = searchParams.get('forceList')
if (forceList !== '1') return
setNotesViewMode(prev => (prev === 'tabs' ? 'masonry' : prev))
setEditingNote(null)
const params = new URLSearchParams(searchParams.toString())
params.delete('forceList')
const newUrl = params.toString() ? `/?${params.toString()}` : '/'
router.replace(newUrl, { scroll: false })
}, [searchParams, router])
const notebookFilter = searchParams.get('notebook')
const handleNoteCreated = useCallback((note: Note) => {
setNotes((prevNotes) => {
const notebookFilter = searchParams.get('notebook')
@@ -123,10 +139,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}
})
// NOTE: No triggerRefresh() — note is added optimistically above.
// triggerRefresh() → getAllNotes() can return stale Next.js cache (note
// created with skipRevalidation:true) and overwrite the freshly-added note.
if (!note.notebookId) {
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
if (wordCount >= 20) {
@@ -140,6 +152,25 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
if (note) setEditingNote({ note, readOnly: false })
}
const handleAddNote = () => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
type: 'richtext',
title: undefined,
notebookId: notebookFilter || undefined,
skipRevalidation: true
})
if (!newNote) return
handleNoteCreated(newNote)
setEditingNote({ note: newNote, readOnly: false })
} catch {
toast.error(t('notes.createFailed') || 'Failed to create note')
}
})
}
const handleOpenHistory = useCallback((note: Note) => {
setHistoryNote(note)
setHistoryOpen(true)
@@ -147,7 +178,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const handleEnableHistory = useCallback(async (noteId: string) => {
await enableNoteHistory(noteId)
// Update the specific note in state
setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n)))
setPinnedNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n)))
setEditingNote((prev) => (prev?.note.id === noteId ? { ...prev, note: { ...prev.note, historyEnabled: true } } : prev))
@@ -168,34 +198,27 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
useReminderCheck(notes)
// Handle ?openNote=ID — open a note from a notification click
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
useEffect(() => {
const openNoteId = searchParams.get('openNote')
if (!openNoteId) return
const openNote = async () => {
// Try to find the note in current state first
const existing = notes.find(n => n.id === openNoteId)
if (existing) {
setEditingNote({ note: existing, readOnly: false })
} else {
// Fetch from server
const fetched = await getNoteById(openNoteId)
if (fetched) {
setEditingNote({ note: fetched, readOnly: false })
}
}
// Clean URL — remove openNote param
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false })
let cancelled = false
const run = async () => {
const existing = notesRef.current.find(n => n.id === openNoteId)
const note = existing ?? (await getNoteById(openNoteId))
if (cancelled || !note) return
setEditingNote(prev => {
if (prev?.note.id === note.id && prev.readOnly === false) return prev
return { note, readOnly: false }
})
}
run()
return () => {
cancelled = true
}
openNote()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
// Listen for global label deletion and immediately update local state
useEffect(() => {
const handler = (e: Event) => {
const { name } = (e as CustomEvent).detail
@@ -215,8 +238,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const prevRefreshKey = useRef(refreshKey)
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
// Les notes initiales suffisent sans filtre
useEffect(() => {
const search = searchParams.get('search')?.trim() || null
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
@@ -227,8 +248,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const isBackgroundRefresh = refreshKey > prevRefreshKey.current
prevRefreshKey.current = refreshKey
// Pour le refreshKey (mutations), toujours recharger
// Pour les filtres, charger depuis le serveur
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
const load = async () => {
@@ -243,14 +262,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
}
// Filtre labels
if (labelFilter.length > 0) {
allNotes = allNotes.filter((note: any) =>
note.labels?.some((label: string) => labelFilter.includes(label))
)
}
// Filtre couleur
if (colorFilter) {
const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter)
@@ -260,7 +277,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
)
}
// Merger avec les tailles locales pour ne pas écraser les modifications
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
@@ -269,20 +285,17 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
setIsLoading(false)
}
// Éviter le rechargement initial si les notes sont déjà chargées sans filtres
if (refreshKey > 0 || hasActiveFilter) {
const cancelled = { value: false }
load().then(() => { if (cancelled.value) return })
return () => { cancelled.value = true }
} else {
// Données initiales : filtrage inbox/notebook côté client seulement
let filtered = initialNotes
if (notebook) {
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
} else {
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
}
// Merger avec les tailles déjà modifiées localement
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
@@ -292,154 +305,132 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, refreshKey])
const { notebooks } = useNotebooks()
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
useEffect(() => {
setControls({
isTabsMode: notesViewMode === 'tabs',
openNoteComposer: () => {},
openNoteComposer: () => handleAddNote(),
})
return () => setControls(null)
}, [notesViewMode, setControls])
const handleNoteCreatedWrapper = (note: any) => {
handleNoteCreated(note)
// Apply sort order to notes
const sortedNotes = useMemo(() => {
const sorted = [...notes]
if (sortOrder === 'newest') sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
if (sortOrder === 'oldest') sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
return sorted
}, [notes, sortOrder])
const sortedPinnedNotes = useMemo(() => {
return sortedNotes.filter(n => n.isPinned)
}, [sortedNotes])
const sortLabels: Record<SortOrder, string> = {
newest: t('sidebar.sortNewest') || 'Plus récentes',
oldest: t('sidebar.sortOldest') || 'Plus anciennes',
alpha: t('sidebar.sortAlpha') || 'A → Z',
}
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<span>{t('nav.notebooks')}</span>
<ChevronRight className="w-4 h-4" />
<span className="font-medium text-primary">{notebookName}</span>
</div>
)
const isTabs = notesViewMode === 'tabs'
const isEditorialMode = !isTabs
const handleEditorClose = useCallback(() => {
setEditingNote(null)
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
const qs = params.toString()
router.replace(qs ? `/?${qs}` : '/', { scroll: false })
// Invalidate notes cache and trigger refresh
refreshNotes(searchParams.get('notebook') || null)
}, [refreshNotes, router, searchParams])
return (
<div
className={cn(
'flex w-full min-h-0 flex-1 flex-col',
isTabs ? 'gap-3 py-1' : 'h-full px-2 py-6 sm:px-4 md:px-8'
isTabs ? 'gap-3 py-1' : 'h-full'
)}
>
{/* Notebook Specific Header */}
{currentNotebook ? (
<div
className={cn(
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
)}
>
<Breadcrumbs notebookName={currentNotebook.name} />
<div className="flex items-start justify-between">
<div className="flex items-center gap-5">
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
{(() => {
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
return (
<Icon
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
/>
)
})()}
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
<span className="text-sm font-medium text-muted-foreground mt-2">({notes.length})</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
</div>
</div>
</div>
{editingNote ? (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={handleEditorClose}
fullPage
/>
) : (
<div
className={cn(
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
)}
>
{!isTabs && <div className="mb-1 h-5" />}
<div className="flex items-start justify-between">
<div className="flex items-center gap-5">
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
<FileText className="w-8 h-8 text-primary" />
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
<span className="text-sm font-medium text-muted-foreground mt-2">{notes.length} {notes.length === 1 ? (t('notes.note') || 'note') : (t('notes.notes') || 'notes')}</span>
<div className="flex-1 overflow-y-auto min-h-0">
<div className={cn(
'px-12 pt-12 pb-8 flex flex-col gap-6',
isEditorialMode ? 'sticky top-0 bg-background/90 backdrop-blur-md z-30 border-b border-foreground/5' : ''
)}>
<div className="flex justify-between items-start">
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pr-12">
{currentNotebook ? currentNotebook.name : t('notes.title')}
</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
{isInbox && !isLoading && notes.length >= 2 && (
<Button
onClick={() => setBatchOrganizationOpen(true)}
variant="outline"
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
title={t('batch.organizeWithAI')}
<div className="flex items-center justify-between border-b border-foreground/5 pb-4">
<button
onClick={handleAddNote}
disabled={isCreating}
className="flex items-center gap-2 text-[13px] text-foreground font-medium hover:opacity-70 transition-opacity"
>
<Plus size={16} />
<span>{t('notes.newNote') || 'Add Note'}</span>
</button>
{/* Sort order */}
<div className="relative">
<button
onClick={() => setShowSortMenu(s => !s)}
className="flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground font-medium transition-opacity"
title={t('sidebar.sortOrder') || 'Sort order'}
>
<Wand2 className="h-4 w-4 text-purple-600" />
<span className="hidden sm:inline">{t('batch.organize')}</span>
</Button>
)}
<ArrowUpDown size={14} />
<span className="hidden sm:inline text-[11px] uppercase tracking-wider font-bold">
{sortOrder === 'newest' ? 'Plus récentes' : sortOrder === 'oldest' ? 'Plus anciennes' : 'A → Z'}
</span>
</button>
<AnimatePresence>
{showSortMenu && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -4 }}
className="absolute right-0 top-full mt-2 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
>
{(['newest', 'oldest', 'alpha'] as SortOrder[]).map(order => (
<button
key={order}
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
className={cn(
'w-full text-left px-4 py-2 text-[12px] transition-colors',
sortOrder === order
? 'font-bold text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
{sortLabels[order]}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
)}
{!isTabs && (
<div
className={cn(
'animate-in fade-in slide-in-from-top-4 duration-300',
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
)}
>
<NoteInput
onNoteCreated={handleNoteCreatedWrapper}
fullWidth={isTabs}
/>
</div>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
) : (
<>
{!isTabs && (
<FavoritesSection
pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
/>
)}
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
<div className="px-12 flex-1 pb-20">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
) : isTabs ? (
<NotesMainSection
viewMode={notesViewMode}
notes={isTabs ? notes : notes.filter((note) => !note.isPinned)}
notes={sortedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
currentNotebookId={searchParams.get('notebook')}
@@ -448,15 +439,55 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onEnableHistory={handleEnableHistory}
onNoteCreated={handleNoteCreated}
/>
</div>
)}
) : (
<div className="max-w-3xl space-y-16">
{sortedPinnedNotes.length > 0 && (
<div className="mb-8">
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-6 px-2">
{t('notes.pinned')}
</h2>
<NotesEditorialView
notes={sortedPinnedNotes}
onOpen={(note: Note, readOnly?: boolean) => setEditingNote({ note, readOnly: readOnly ?? false })}
notebookName={currentNotebook?.name}
onOpenHistory={handleOpenHistory}
/>
</div>
)}
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && !isTabs && (
<div className="text-center py-8 text-gray-500">
{t('notes.emptyState')}
</div>
)}
</>
{sortedNotes.filter((note) => !note.isPinned).length > 0 && (
<NotesEditorialView
notes={sortedNotes.filter((note) => !note.isPinned)}
onOpen={(note, readOnly) => setEditingNote({ note, readOnly })}
notebookName={currentNotebook?.name}
onOpenHistory={handleOpenHistory}
/>
)}
{notes.length === 0 && (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<p className="font-memento-serif text-xl italic text-muted-foreground">
{t('notes.emptyState') || 'This notebook is waiting for its first vision.'}
</p>
<button
onClick={handleAddNote}
disabled={isCreating}
className="px-6 py-2 border border-foreground text-[13px] uppercase tracking-[0.2em] hover:bg-foreground hover:text-background transition-all"
>
{t('notes.createFirst') || 'Begin Drawing'}
</button>
</div>
)}
</div>
)}
</div>
<footer className="px-12 py-6 border-t border-foreground/5 text-center mt-auto">
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-medium">
Memento &mdash; {new Date().getFullYear()}
</p>
</footer>
</div>
)}
<MemoryEchoNotification onOpenNote={handleOpenNote} />
@@ -489,14 +520,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
/>
)}
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
<NoteHistoryModal
open={historyOpen}
onOpenChange={setHistoryOpen}

View File

@@ -4,7 +4,7 @@ import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { LABEL_COLORS } from '@/lib/types'
import { useLabels } from '@/context/LabelContext'
import { useNotebooks } from '@/context/notebooks-context'
interface LabelBadgeProps {
label: string
@@ -23,7 +23,7 @@ export function LabelBadge({
isSelected = false,
isDisabled = false,
}: LabelBadgeProps) {
const { getLabelColor } = useLabels()
const { getLabelColor } = useNotebooks()
const colorName = getLabelColor(label)
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
@@ -37,7 +37,7 @@ export function LabelBadge({
variant === 'filter' && 'cursor-pointer hover:opacity-80',
variant === 'clickable' && 'cursor-pointer',
isDisabled && 'opacity-50',
isSelected && 'ring-2 ring-blue-500'
isSelected && 'ring-2 ring-primary'
)}
onClick={onClick}
>

View File

@@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Filter, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { useNotebooks } from '@/context/notebooks-context'
import { LabelBadge } from './label-badge'
import { useLanguage } from '@/lib/i18n'
@@ -23,7 +23,7 @@ interface LabelFilterProps {
}
export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) {
const { labels, loading } = useLabels()
const { labels, isLoading: loading } = useNotebooks()
const { t, language } = useLanguage()
const [allLabelNames, setAllLabelNames] = useState<string[]>([])

View File

@@ -14,9 +14,9 @@ import {
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { useNotebooks } from '@/context/notebooks-context'
import { useLanguage } from '@/lib/i18n'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useRefresh } from '@/lib/use-refresh'
export interface LabelManagementDialogProps {
/** Mode contrôlé (ex. ouverture depuis la liste des carnets) */
@@ -26,9 +26,9 @@ export interface LabelManagementDialogProps {
export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
const { open, onOpenChange } = props
const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
const { labels, isLoading: loading, addLabel, updateLabel, deleteLabel } = useNotebooks()
const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefresh()
const { refreshLabels } = useRefresh()
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const [newLabel, setNewLabel] = useState('')
const [editingColorId, setEditingColorId] = useState<string | null>(null)
@@ -40,7 +40,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
if (trimmed) {
try {
await addLabel(trimmed, 'gray')
triggerRefresh()
refreshLabels()
setNewLabel('')
} catch (error) {
console.error('Failed to add label:', error)
@@ -52,7 +52,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
try {
const labelToDelete = labels.find(l => l.id === id)
await deleteLabel(id)
triggerRefresh()
refreshLabels()
if (labelToDelete) {
window.dispatchEvent(new CustomEvent('label-deleted', { detail: { name: labelToDelete.name } }))
}
@@ -65,7 +65,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
const handleChangeColor = async (id: string, color: LabelColorName) => {
try {
await updateLabel(id, { color })
triggerRefresh()
refreshLabels()
setEditingColorId(null)
} catch (error) {
console.error('Failed to update label color:', error)

View File

@@ -16,7 +16,7 @@ import { Badge } from './ui/badge'
import { Tag, X, Plus, Palette, AlertCircle } from 'lucide-react'
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useLabels, Label } from '@/context/LabelContext'
import { useNotebooks } from '@/context/notebooks-context'
import { useLanguage } from '@/lib/i18n'
interface LabelManagerProps {
@@ -26,7 +26,7 @@ interface LabelManagerProps {
}
export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelManagerProps) {
const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useLabels()
const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useNotebooks()
const { t } = useLanguage()
const [open, setOpen] = useState(false)
const [newLabel, setNewLabel] = useState('')
@@ -45,18 +45,11 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
if (trimmed && !selectedLabels.includes(trimmed)) {
try {
// NotebookId is REQUIRED for label creation (PRD R2)
if (!notebookId) {
setErrorMessage(t('labels.notebookRequired'))
console.error(t('labels.notebookRequired'))
return
}
// Get existing label color or use random
const existingLabel = labels.find(l => l.name === trimmed)
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
await addLabel(trimmed, color, notebookId)
await addLabel(trimmed, color)
const updated = [...selectedLabels, trimmed]
setSelectedLabels(updated)
setNewLabel('')

View File

@@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Tag, Plus, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { useNotebooks } from '@/context/notebooks-context'
import { LabelBadge } from './label-badge'
import { useLanguage } from '@/lib/i18n'
@@ -26,7 +26,7 @@ export function LabelSelector({
triggerLabel,
align = 'start',
}: LabelSelectorProps) {
const { labels, loading, addLabel } = useLabels()
const { labels, isLoading: loading, addLabel } = useNotebooks()
const { t } = useLanguage()
const [search, setSearch] = useState('')

View File

@@ -0,0 +1,224 @@
'use client'
/**
* MarkdownSlashCommands
* Detects "/" typed in a textarea and shows a floating command palette.
* Supports keyboard navigation (↑↓ Enter Esc) and replaces the "/cmd" text
* with the appropriate markdown syntax.
*/
import { useEffect, useRef, useState, useCallback } from 'react'
import { createPortal } from 'react-dom'
import {
Heading1, Heading2, Heading3, List, ListOrdered,
CheckSquare, Quote, Code, Minus, Bold, Italic,
Pilcrow, Link as LinkIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
type SlashCmd = {
title: string
desc: string
icon: typeof Heading1
keywords: string[]
insert: string
cursorOffset?: number // chars from end where cursor lands
}
const COMMANDS: SlashCmd[] = [
{ title: 'Texte', desc: 'Paragraphe normal', icon: Pilcrow, keywords: ['text', 'texte', 'p'], insert: '' },
{ title: 'Titre 1', desc: 'Grand titre de section', icon: Heading1, keywords: ['h1', '1', 'heading', 'titre'], insert: '# ' },
{ title: 'Titre 2', desc: 'Titre de niveau 2', icon: Heading2, keywords: ['h2', '2', 'titre'], insert: '## ' },
{ title: 'Titre 3', desc: 'Titre de niveau 3', icon: Heading3, keywords: ['h3', '3', 'titre'], insert: '### ' },
{ title: 'Liste à puces', desc: 'Liste non ordonnée', icon: List, keywords: ['list', 'liste', 'ul', 'bullet', '-'], insert: '- ' },
{ title: 'Liste numérotée', desc: 'Liste ordonnée', icon: ListOrdered, keywords: ['ol', 'numbered', 'numéro', '1.'], insert: '1. ' },
{ title: 'Tâche (to-do)', desc: 'Case à cocher', icon: CheckSquare, keywords: ['todo', 'tache', 'task', 'checkbox', '[]'], insert: '- [ ] ' },
{ title: 'Citation', desc: 'Bloc de citation', icon: Quote, keywords: ['quote', 'citation', 'blockquote', '>'], insert: '> ' },
{ title: 'Bloc de code', desc: 'Snippet de code', icon: Code, keywords: ['code', 'block', '```'], insert: '```\n', cursorOffset: 4 },
{ title: 'Séparateur', desc: 'Ligne horizontale', icon: Minus, keywords: ['hr', 'divider', 'separator', '---'], insert: '---\n' },
{ title: 'Gras', desc: '**texte en gras**', icon: Bold, keywords: ['bold', 'gras', '**'], insert: '****', cursorOffset: 2 },
{ title: 'Italique', desc: '*texte en italique*', icon: Italic, keywords: ['italic', 'italique', '*'], insert: '**', cursorOffset: 1 },
{ title: 'Lien', desc: '[texte](url)', icon: LinkIcon, keywords: ['link', 'lien', 'url'], insert: '[]()', cursorOffset: 3 },
]
interface MarkdownSlashCommandsProps {
textareaRef: React.RefObject<HTMLTextAreaElement>
value: string
onChange: (value: string) => void
}
export function MarkdownSlashCommands({ textareaRef, value, onChange }: MarkdownSlashCommandsProps) {
const { t } = useLanguage()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [coords, setCoords] = useState({ top: 0, left: 0 })
const slashPosRef = useRef<number>(-1)
const menuRef = useRef<HTMLDivElement>(null)
const filtered = COMMANDS.filter(cmd => {
if (!query) return true
const q = query.toLowerCase()
return cmd.keywords.some(k => k.includes(q)) || cmd.title.toLowerCase().includes(q)
})
const close = useCallback(() => {
setOpen(false)
setQuery('')
setSelectedIndex(0)
slashPosRef.current = -1
}, [])
const getCaretCoords = useCallback(() => {
const el = textareaRef.current
if (!el) return { top: 0, left: 0 }
const rect = el.getBoundingClientRect()
// Approximate caret position using a mirror div
const mirror = document.createElement('div')
const style = window.getComputedStyle(el)
;['font', 'fontSize', 'fontFamily', 'fontWeight', 'lineHeight',
'paddingTop', 'paddingLeft', 'paddingRight', 'borderTop', 'borderLeft',
'whiteSpace', 'wordWrap', 'overflowWrap'].forEach(p => {
(mirror.style as any)[p] = (style as any)[p]
})
mirror.style.position = 'absolute'
mirror.style.visibility = 'hidden'
mirror.style.width = `${el.offsetWidth}px`
mirror.style.top = '0'
mirror.style.left = '0'
document.body.appendChild(mirror)
const textBefore = el.value.substring(0, el.selectionStart ?? 0)
mirror.textContent = textBefore
const span = document.createElement('span')
span.textContent = '|'
mirror.appendChild(span)
const spanRect = span.getBoundingClientRect()
document.body.removeChild(mirror)
return {
top: rect.top + el.scrollTop + spanRect.top - mirror.getBoundingClientRect().top + span.offsetHeight + 8,
left: Math.min(rect.left + spanRect.left - mirror.getBoundingClientRect().left, rect.right - 240),
}
}, [textareaRef])
const applyCommand = useCallback((cmd: SlashCmd) => {
const el = textareaRef.current
if (!el || slashPosRef.current < 0) { close(); return }
const cursorPos = el.selectionStart ?? 0
const before = value.substring(0, slashPosRef.current)
const after = value.substring(cursorPos)
if (cmd.insert === '') {
// "Text" — just remove the slash
const newVal = before + after
onChange(newVal)
setTimeout(() => {
el.focus()
const pos = slashPosRef.current
el.setSelectionRange(pos, pos)
}, 0)
} else {
const newVal = before + cmd.insert + after
onChange(newVal)
setTimeout(() => {
el.focus()
const offset = cmd.cursorOffset ?? 0
const pos = slashPosRef.current + cmd.insert.length - offset
el.setSelectionRange(pos, pos)
}, 0)
}
close()
}, [value, onChange, close, textareaRef])
// Monitor textarea input
useEffect(() => {
const el = textareaRef.current
if (!el) return
const handleInput = () => {
const pos = el.selectionStart ?? 0
const text = el.value
// Look for "/" at start of word
const lineStart = text.lastIndexOf('\n', pos - 1) + 1
const lineText = text.substring(lineStart, pos)
const slashMatch = lineText.match(/\/(\S*)$/)
if (slashMatch) {
slashPosRef.current = pos - slashMatch[0].length
setQuery(slashMatch[1])
setSelectedIndex(0)
setOpen(true)
const c = getCaretCoords()
setCoords(c)
} else {
if (open) close()
}
}
el.addEventListener('input', handleInput)
return () => el.removeEventListener('input', handleInput)
}, [textareaRef, open, close, getCaretCoords])
// Keyboard navigation
useEffect(() => {
if (!open) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length) }
else if (e.key === 'Enter') { e.preventDefault(); if (filtered[selectedIndex]) applyCommand(filtered[selectedIndex]) }
else if (e.key === 'Escape') { e.preventDefault(); close() }
}
document.addEventListener('keydown', handleKey, true)
return () => document.removeEventListener('keydown', handleKey, true)
}, [open, filtered, selectedIndex, applyCommand, close])
// Close on outside click
useEffect(() => {
if (!open) return
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) close()
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open, close])
if (!open || filtered.length === 0) return null
if (typeof document === 'undefined') return null
return createPortal(
<div
ref={menuRef}
style={{ position: 'fixed', top: coords.top, left: coords.left, zIndex: 9999 }}
className="w-60 bg-card border border-border rounded-xl shadow-xl overflow-hidden"
>
<div className="px-3 py-2 border-b border-border/40">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">
{t('richTextEditor.slashHint') || 'Commandes Markdown'}
</p>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{filtered.map((cmd, idx) => (
<button
key={cmd.title}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors',
idx === selectedIndex ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
onMouseDown={e => { e.preventDefault(); applyCommand(cmd) }}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="w-7 h-7 rounded-lg border border-border bg-background flex items-center justify-center shrink-0">
<cmd.icon className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<p className="text-[12px] font-medium leading-none truncate">{cmd.title}</p>
<p className="text-[10px] text-muted-foreground/70 mt-0.5 truncate">{cmd.desc}</p>
</div>
</button>
))}
</div>
</div>,
document.body
)
}

View File

@@ -22,7 +22,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Note } from '@/lib/types';
import { NoteCard } from './note-card';
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
import { useNotebookDrag } from '@/context/notebook-drag-context';
import { useEditorUI } from '@/context/editor-ui-context';
import { useLanguage } from '@/lib/i18n';
import { useCardSizeMode } from '@/hooks/use-card-size-mode';
import dynamic from 'next/dynamic';
@@ -175,7 +175,7 @@ export function MasonryGrid({
}: MasonryGridProps) {
const { t } = useLanguage();
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
const { startDrag, endDrag, draggedNoteId } = useEditorUI();
const cardSizeMode = useCardSizeMode();
const isUniformMode = cardSizeMode === 'uniform';

View File

@@ -58,10 +58,9 @@ const ConnectionsOverlay = dynamic(() => import('./connections-overlay').then(m
const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({ default: m.ComparisonModal })), { ssr: false })
const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false })
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { useRefresh } from '@/lib/use-refresh'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
// Mapping of supported languages to date-fns locales
@@ -150,7 +149,7 @@ function getAvatarColor(name: string): string {
'bg-amber-600',
'bg-pink-600',
'bg-teal-600',
'bg-blue-600',
'bg-stone-600',
'bg-indigo-600',
]
@@ -172,11 +171,10 @@ export const NoteCard = memo(function NoteCard({
}: NoteCardProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { refreshLabels } = useLabels()
const { triggerRefresh } = useNoteRefresh()
const { refreshNotes } = useRefresh()
const { data: session } = useSession()
const { t, language } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const { notebooks, moveNoteToNotebookOptimistic, refreshLabels } = useNotebooks()
const [, startTransition] = useTransition()
const [isDeleting, setIsDeleting] = useState(false)
const [isHidden, setIsHidden] = useState(false)
@@ -203,7 +201,7 @@ export const NoteCard = memo(function NoteCard({
try {
await updateNote(noteId, { reminder })
setReminderDate(reminder)
triggerRefresh()
refreshNotes(note?.notebookId)
if (reminder) {
toast.success(t('notes.reminderSet', { datetime: reminder.toLocaleString() }))
} else {
@@ -219,7 +217,7 @@ export const NoteCard = memo(function NoteCard({
const handleMoveToNotebook = async (notebookId: string | null) => {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setShowNotebookMenu(false)
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
// No need for router.refresh() - refreshNotes(note?.notebookId) is already called in moveNoteToNotebookOptimistic
}
// Optimistic UI state for instant feedback
@@ -304,7 +302,7 @@ export const NoteCard = memo(function NoteCard({
try {
await deleteNote(note.id)
await refreshLabels()
triggerRefresh() // met à jour la liste et le compteur du carnet
refreshNotes(note?.notebookId) // met à jour la liste et le compteur du carnet
} catch (error) {
console.error('Failed to delete note:', error)
setIsHidden(false)
@@ -317,7 +315,7 @@ export const NoteCard = memo(function NoteCard({
setIsHidden(true)
try {
await restoreNote(note.id)
triggerRefresh()
refreshNotes(note?.notebookId)
toast.success(t('trash.noteRestored'))
} catch (error) {
console.error('Failed to restore note:', error)
@@ -331,7 +329,7 @@ export const NoteCard = memo(function NoteCard({
setIsHidden(true)
try {
await permanentDeleteNote(note.id)
triggerRefresh()
refreshNotes(note?.notebookId)
toast.success(t('trash.notePermanentlyDeleted'))
} catch (error) {
console.error('Failed to permanently delete note:', error)
@@ -344,7 +342,7 @@ export const NoteCard = memo(function NoteCard({
startTransition(async () => {
addOptimisticNote({ isPinned: !note.isPinned })
await togglePin(note.id, !note.isPinned)
triggerRefresh()
refreshNotes(note?.notebookId)
if (!note.isPinned) {
toast.success(t('notes.pinned') || 'Note pinned')
@@ -358,7 +356,7 @@ export const NoteCard = memo(function NoteCard({
startTransition(async () => {
addOptimisticNote({ isArchived: !note.isArchived })
await toggleArchive(note.id, !note.isArchived)
triggerRefresh()
refreshNotes(note?.notebookId)
})
}
@@ -456,9 +454,9 @@ export const NoteCard = memo(function NoteCard({
}}
onDragEnd={() => onDragEnd?.()}
className={cn(
'note-card group relative rounded-lg overflow-hidden p-6 border-transparent shadow-sm',
'note-card group relative rounded-lg overflow-hidden p-6 border border-transparent shadow-[0_2px_4px_rgba(0,0,0,0.04),0_4px_12px_rgba(0,0,0,0.04)]',
'transition-all duration-200 ease-out',
'hover:shadow-md hover:border-border/50 hover:-translate-y-0.5',
'hover:shadow-[0_4px_8px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.08)] hover:border-border/40 hover:-translate-y-0.5',
colorClasses.bg,
colorClasses.card,
colorClasses.hover,
@@ -811,7 +809,7 @@ export const NoteCard = memo(function NoteCard({
}
toast.success(t('toast.notesFusionSuccess'))
setFusionNotes([])
triggerRefresh()
refreshNotes(note?.notebookId)
}}
/>
</div>

View File

@@ -0,0 +1,243 @@
'use client'
import { useState, useMemo } from 'react'
import { Note } from '@/lib/types'
import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { LabelBadge } from './label-badge'
import { NoteHistoryModal } from './note-history-modal'
import { enableNoteHistory } from '@/app/actions/notes'
type Tab = 'info' | 'versions'
interface NoteDocumentInfoPanelProps {
note: Note
content: string
onClose: () => void
onNoteRestored?: (note: Note) => void
}
function getLocale(lang: string) {
return lang === 'fr' ? fr : enUS
}
function wordCount(text: string) {
return text.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length
}
function charCount(text: string) {
return text.replace(/<[^>]+>/g, '').length
}
const noteTypeLabel: Record<string, string> = {
richtext: 'Rich Text',
markdown: 'Markdown',
text: 'Texte',
checklist: 'Liste de tâches',
}
export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }: NoteDocumentInfoPanelProps) {
const { t, language } = useLanguage()
const { notebooks } = useNotebooks()
const [activeTab, setActiveTab] = useState<Tab>('info')
const [showHistory, setShowHistory] = useState(false)
const [historyEnabled, setHistoryEnabled] = useState(note.historyEnabled ?? false)
const locale = getLocale(language)
const notebook = useMemo(
() => notebooks.find(nb => nb.id === note.notebookId),
[notebooks, note.notebookId]
)
const words = useMemo(() => wordCount(content), [content])
const chars = useMemo(() => charCount(content), [content])
const createdAt = note.createdAt ? new Date(note.createdAt as unknown as string) : null
const updatedAt = note.contentUpdatedAt ? new Date(note.contentUpdatedAt as unknown as string) : null
return (
<>
<div className="flex w-80 shrink-0 flex-col border-l border-border/40 bg-background overflow-hidden">
{/* Header tabs */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border/40">
<div className="flex gap-1">
{(['info', 'versions'] as Tab[]).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold transition-all',
activeTab === tab
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
{tab === 'info' && <Info className="h-3 w-3" />}
{tab === 'versions' && <Clock className="h-3 w-3" />}
{tab === 'info' ? 'Info' : 'Versions'}
</button>
))}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{/* ── INFO TAB ── */}
{activeTab === 'info' && (
<div>
{/* Stats — grand display numbers */}
<div className="grid grid-cols-2 border-b border-border/30">
<div className="flex flex-col items-center gap-1 py-6 border-r border-border/30">
<span className="text-4xl font-bold font-memento-serif tabular-nums tracking-tight">{words}</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-semibold">mots</span>
</div>
<div className="flex flex-col items-center gap-1 py-6">
<span className="text-4xl font-bold font-memento-serif tabular-nums tracking-tight">{chars}</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-semibold">caractères</span>
</div>
</div>
<div className="divide-y divide-border/30">
{notebook && (
<div className="flex items-start gap-3 px-4 py-3">
<Book className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Carnet</p>
<p className="text-sm font-medium">{notebook.name}</p>
</div>
</div>
)}
<div className="flex items-start gap-3 px-4 py-3">
<FileText className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Type</p>
<p className="text-sm font-medium">{noteTypeLabel[note.type] || note.type}</p>
</div>
</div>
{createdAt && (
<div className="flex items-start gap-3 px-4 py-3">
<Calendar className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Créée le</p>
<p className="text-sm font-medium">{format(createdAt, 'd MMM yyyy', { locale })}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
{formatDistanceToNow(createdAt, { addSuffix: true, locale })}
</p>
</div>
</div>
)}
{updatedAt && (
<div className="flex items-start gap-3 px-4 py-3">
<Clock className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Modifiée</p>
<p className="text-sm font-medium">{format(updatedAt, 'd MMM yyyy · HH:mm', { locale })}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
{formatDistanceToNow(updatedAt, { addSuffix: true, locale })}
</p>
</div>
</div>
)}
{(note.labels ?? []).length > 0 && (
<div className="flex items-start gap-3 px-4 py-3">
<Tag className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-1.5">Labels</p>
<div className="flex flex-wrap gap-1">
{(note.labels ?? []).map(label => (
<LabelBadge key={label} label={label} />
))}
</div>
</div>
</div>
)}
<div className="flex items-start gap-3 px-4 py-3">
<Hash className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">ID</p>
<p className="text-[11px] text-muted-foreground font-mono truncate">{note.id}</p>
</div>
</div>
</div>
</div>
)}
{/* ── VERSIONS TAB ── */}
{activeTab === 'versions' && (
<div className="p-4 space-y-4">
{!historyEnabled ? (
<div className="text-center py-6 space-y-3">
<Clock className="h-8 w-8 text-muted-foreground/30 mx-auto" />
<p className="text-sm text-muted-foreground">L'historique n'est pas activé pour cette note.</p>
<button
className="text-xs px-4 py-2 rounded-lg bg-foreground text-background font-medium hover:opacity-80 transition-opacity"
onClick={async () => {
await enableNoteHistory(note.id)
setHistoryEnabled(true)
setShowHistory(true)
}}
>
Activer l'historique
</button>
</div>
) : (
<div className="space-y-2">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-3">Versions sauvegardées</p>
<button
className="w-full flex items-center justify-between p-3 rounded-xl border border-border hover:bg-muted transition-colors text-left"
onClick={() => setShowHistory(true)}
>
<div className="flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" />
<div>
<p className="text-sm font-medium">Voir l'historique</p>
<p className="text-[11px] text-muted-foreground">Comparer et restaurer des versions</p>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
</div>
)}
</div>
)}
</div>
</div>
{/* NoteHistoryModal with correct props */}
{showHistory && (
<NoteHistoryModal
open={showHistory}
onOpenChange={(v) => setShowHistory(v)}
note={note}
enabled={historyEnabled}
onEnableHistory={async () => {
await enableNoteHistory(note.id)
setHistoryEnabled(true)
}}
onRestored={(restored) => {
setShowHistory(false)
onNoteRestored?.(restored)
}}
/>
)}
</>
)
}

View File

@@ -25,8 +25,12 @@ import {
} from '@/components/ui/dropdown-menu'
import { NoteTypeSelector } from '@/components/note-type-selector'
import { RichTextEditor } from '@/components/rich-text-editor'
import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut } from 'lucide-react'
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut, ArrowLeft, ChevronRight, Info, Check, Loader2, Save, MoreHorizontal, Trash2 } from 'lucide-react'
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
import { format } from 'date-fns'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { MarkdownSlashCommands } from './markdown-slash-commands'
import { NoteDocumentInfoPanel } from './note-document-info-panel'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { cn, extractImagesFromHTML } from '@/lib/utils'
import { toast } from 'sonner'
@@ -57,9 +61,10 @@ interface NoteEditorProps {
note: Note
readOnly?: boolean
onClose: () => void
fullPage?: boolean
}
export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps) {
export function NoteEditor({ note, readOnly = false, onClose, fullPage = false }: NoteEditorProps) {
const { data: session } = useSession()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
@@ -147,6 +152,17 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
// AI processing state for ActionBar
const [isProcessingAI, setIsProcessingAI] = useState(false)
const [aiOpen, setAiOpen] = useState(false)
const [infoOpen, setInfoOpen] = useState(false)
const [isDirty, setIsDirty] = useState(false)
// fullPage — auto title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
content,
enabled: fullPage && !title && !dismissedTitleSuggestions,
})
// Track previous content for copilot action undo
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
@@ -242,6 +258,28 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
}, [t])
// Auto-grow textarea as content grows
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
}, [content])
// Also auto-grow when switching FROM preview TO edit mode
useEffect(() => {
if (showMarkdownPreview) return // we're in preview, textarea not mounted
// Defer one frame so the textarea is in the DOM
const raf = requestAnimationFrame(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
el.focus()
})
return () => cancelAnimationFrame(raf)
}, [showMarkdownPreview])
const handleRemoveImage = (index: number) => {
const removedUrl = images[index]
setImages(images.filter((_, i) => i !== index))
@@ -291,12 +329,6 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
.join(' ')
.trim()
const allImages = useMemo(() => {
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
return Array.from(new Set([...images, ...extracted]));
}, [images, content, noteType]);
const wordCount = fullContentForAI.split(/\s+/).filter(word => word.length > 0).length
@@ -642,6 +674,409 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
}
}
// Save in place (fullPage) — without closing
const handleSaveInPlace = async () => {
setIsSaving(true)
try {
await updateNote(note.id, {
title: title.trim() || null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
labels,
images,
links,
color,
reminder: currentReminder,
isMarkdown: noteType === 'markdown',
type: noteType,
size,
})
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
await refreshLabels()
triggerRefresh()
setIsDirty(false)
toast.success('Note sauvegardée !')
} catch (error) {
console.error('Failed to save note:', error)
toast.error('Erreur lors de la sauvegarde.')
} finally {
setIsSaving(false)
}
}
// Ctrl+S / Cmd+S shortcut — save in place in fullPage mode
// Note: this useEffect must be outside the if(fullPage) branch (no conditional hooks)
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (!fullPage) return
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSaveInPlace()
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fullPage, isSaving])
if (fullPage) {
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
return (
<>
{/* ── outer container ── */}
<div className="h-full flex items-stretch overflow-hidden transition-all duration-500">
{/* ── main scrollable column ── */}
<div className="flex-1 flex flex-col overflow-y-auto bg-background">
{/* TOOLBAR — px-12 py-8, bg-white/90, rounded-full buttons */}
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 dark:bg-zinc-950/90 backdrop-blur-sm z-40 border-b border-black/10 dark:border-white/10">
{/* Left: back */}
<button
onClick={onClose}
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">Back to collection</span>
</button>
{/* Right: status + type + AI + Info */}
<div className="flex items-center gap-4">
{/* Save status */}
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
{isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving</span></>
: isDirty
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>Modified</span></>
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
</span>
{/* Note type */}
<NoteTypeSelector
value={noteType}
onChange={(newType) => { setNoteType(newType); setShowMarkdownPreview(newType === 'markdown'); setIsDirty(true) }}
compact
/>
{/* Preview toggle — only for text/markdown, in toolbar where it's visible */}
{(noteType === 'text' || noteType === 'markdown') && !readOnly && (
<button
onClick={() => setShowMarkdownPreview(v => !v)}
title={showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
showMarkdownPreview
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Eye size={16} />
<span>{showMarkdownPreview ? 'Éditer' : 'Aperçu'}</span>
</button>
)}
{/* AI — rounded-full, exact prototype style */}
<button
onClick={() => { setAiOpen(v => !v); setInfoOpen(false) }}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
aiOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Sparkles size={16} />
<span>AI Assistant</span>
</button>
{/* Info — rounded-full */}
<button
onClick={() => { setInfoOpen(v => !v); setAiOpen(false) }}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
infoOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Info size={16} />
<span>Document Info</span>
</button>
{/* Save button */}
{!readOnly && (
<button
onClick={handleSaveInPlace}
disabled={isSaving || !isDirty}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
<span>{isSaving ? 'Saving…' : 'Save'}</span>
</button>
)}
{/* Three-dot options menu */}
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={async () => {
try {
await deleteNote(note.id)
triggerRefresh()
toast.success('Note supprimée.')
onClose()
} catch { toast.error('Impossible de supprimer.') }
}}
className="text-red-600 dark:text-red-400 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer la note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* BODY — max-w-4xl, px-12, py-16 */}
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
{/* Breadcrumb + Title block */}
<div className="space-y-4">
{/* Breadcrumb: Notebook Date */}
<div className="flex items-center gap-3 text-[12px] text-foreground/50 uppercase tracking-[.25em] font-bold">
{notebookName && <span>{notebookName}</span>}
{notebookName && <ChevronRight size={10} />}
<span suppressHydrationWarning>
{format(new Date(note.contentUpdatedAt), 'MMM d, yyyy')}
</span>
</div>
{/* Title — auto-resizing textarea to prevent overflow */}
<div className="group relative">
<textarea
dir="auto"
rows={1}
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
value={title}
onChange={(e) => { setTitle(e.target.value); setIsDirty(true); setDismissedTitleSuggestions(true) }}
onInput={(e) => {
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}}
disabled={readOnly}
className={cn(
'w-full text-4xl md:text-5xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight',
'placeholder:text-foreground/20 resize-none overflow-hidden',
!readOnly && 'pr-12'
)}
/>
{/* AI title generation — always visible on hover */}
{!readOnly && (
<button
type="button"
onClick={async () => {
const plain = content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
if (wordCount < 10) {
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
return
}
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }),
})
if (res.ok) {
const data = await res.json()
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
setTitle(s)
setIsDirty(true)
toast.success('Titre généré !')
} else {
toast.error('Impossible de générer un titre.')
}
} else {
toast.error('Erreur lors de la génération du titre.')
}
} catch { toast.error('Erreur réseau.') } finally { setIsProcessingAI(false) }
}}
disabled={isProcessingAI}
className="absolute right-0 top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"
title="Générer un titre automatique avec l'IA"
>
{isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
</button>
)}
</div>
{/* Auto title suggestions */}
{!title && !dismissedTitleSuggestions && autoTitleSuggestions.length > 0 && (
<TitleSuggestions
suggestions={autoTitleSuggestions}
onSelect={(s) => { setTitle(s); setDismissedTitleSuggestions(true); setIsDirty(true) }}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
)}
</div>
{/* Hero image — show first note image if present */}
{allImages.length > 0 && (
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
<img
src={allImages[0]}
alt={title}
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500"
/>
</div>
)}
{/* Content area — max-w-3xl for wider reading column */}
<div className="max-w-3xl mx-auto w-full pb-32">
{noteType === 'richtext' ? (
<div className="fullpage-editor">
<RichTextEditor
content={content}
onChange={(v) => { setContent(v); setIsDirty(true) }}
className="min-h-[280px]"
onImageUpload={uploadImageFile}
/>
</div>
) : (noteType === 'markdown' || noteType === 'text') && showMarkdownPreview ? (
<div
className="min-h-[280px] cursor-text prose prose-lg dark:prose-invert max-w-none leading-relaxed"
onClick={() => !readOnly && setShowMarkdownPreview(false)}
>
<MarkdownContent content={content} />
{!readOnly && (
<p className="text-[11px] text-foreground/30 mt-8 select-none not-prose italic">
Cliquez pour éditer
</p>
)}
</div>
) : (
<div className="relative">
<textarea
ref={textareaRef}
dir="auto"
placeholder={t('notes.takeNote') || "Commencez à écrire… tapez '/' pour les commandes"}
value={content}
onFocus={() => setShowMarkdownPreview(false)}
onChange={(e) => { setContent(e.target.value); setIsDirty(true) }}
disabled={readOnly}
className="w-full min-h-[280px] border-0 outline-none px-0 bg-transparent text-xl leading-relaxed resize-none overflow-hidden placeholder:text-foreground/30 text-foreground"
/>
{!readOnly && (
<MarkdownSlashCommands
textareaRef={textareaRef as React.RefObject<HTMLTextAreaElement>}
value={content}
onChange={(v) => { setContent(v); setIsDirty(true) }}
/>
)}
</div>
)}
</div>
</div>
</div>
{/* ── Side panel: AI Chat ── */}
{aiOpen && (
<div className="h-full self-stretch bg-background flex flex-col z-50 shrink-0">
<ContextualAIChat
onClose={() => setAiOpen(false)}
noteTitle={title}
noteContent={content}
noteImages={allImages}
noteId={note.id}
onApplyToNote={(nc) => {
setPreviousContentForCopilot(content)
setContent(nc)
setIsDirty(true)
if (noteType === 'markdown') setShowMarkdownPreview(true)
}}
onUndoLastAction={previousContentForCopilot !== null ? () => { setContent(previousContentForCopilot!); setPreviousContentForCopilot(null) } : undefined}
lastActionApplied={previousContentForCopilot !== null}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
diagramInsertFormat={noteType === 'richtext' ? 'html' : 'markdown'}
onGenerateTitle={async () => {
const plain = content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
if (wordCount < 10) {
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
return
}
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }),
})
if (res.ok) {
const data = await res.json()
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
setTitle(s)
setIsDirty(true)
toast.success('Titre généré !')
} else {
toast.error('Impossible de générer un titre.')
}
} else {
toast.error('Erreur lors de la génération du titre.')
}
} catch { toast.error('Erreur réseau.') } finally { setIsProcessingAI(false) }
}}
/>
</div>
)}
{/* ── Side panel: Document Info ── */}
{infoOpen && (
<div className="w-[400px] h-full self-stretch border-l border-black/10 dark:border-white/10 bg-background flex flex-col z-50 shrink-0">
<NoteDocumentInfoPanel
note={note}
content={content}
onClose={() => setInfoOpen(false)}
onNoteRestored={(r) => { setContent(r.content || ''); setTitle(r.title || ''); setIsDirty(false) }}
/>
</div>
)}
</div>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
<ReminderDialog
open={showReminderDialog}
onOpenChange={setShowReminderDialog}
currentReminder={currentReminder}
onSave={handleReminderSave}
onRemove={handleRemoveReminder}
/>
</>
)
}
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent

View File

@@ -0,0 +1,37 @@
'use client'
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
import { NoteEditorFullPage } from './note-editor-full-page'
import { NoteEditorDialog } from './note-editor-dialog'
import { Note } from '@/lib/types'
interface NoteEditorProps {
note: Note
readOnly?: boolean
onClose: () => void
fullPage?: boolean
}
export function NoteEditor({ note, readOnly, onClose, fullPage = false }: NoteEditorProps) {
return (
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage}>
{fullPage ? (
<NoteEditorFullPage onClose={onClose} />
) : (
<NoteEditorDialog onClose={onClose} />
)}
</NoteEditorProvider>
)
}
// Re-export context hook for backwards compatibility
export { useNoteEditorContext } from './note-editor-context'
// Re-export sub-components for advanced usage
export { NoteEditorFullPage } from './note-editor-full-page'
export { NoteEditorDialog } from './note-editor-dialog'
export { NoteEditorProvider } from './note-editor-context'
export { NoteTitleBlock } from './note-title-block'
export { NoteContentArea } from './note-content-area'
export { NoteMetadataSection } from './note-metadata-section'
export { NoteEditorToolbar } from './note-editor-toolbar'

View File

@@ -0,0 +1,179 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { RichTextEditor } from '@/components/rich-text-editor'
import { MarkdownContent } from '@/components/markdown-content'
import { MarkdownSlashCommands } from '@/components/markdown-slash-commands'
import { GhostTags } from '@/components/ghost-tags'
import { Textarea } from '@/components/ui/textarea'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { X, Plus } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
export function NoteContentArea() {
const { state, actions, readOnly, fullPage, textareaRef } = useNoteEditorContext()
const { t } = useLanguage()
const uploadImageFile = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', { method: 'POST', body: formData })
if (!response.ok) throw new Error('Upload failed')
const data = await response.json()
return data.url
}
if (state.noteType === 'richtext') {
if (fullPage) {
return (
<div className="fullpage-editor">
<RichTextEditor
content={state.content}
onChange={(v: string) => actions.setContent(v)}
className="min-h-[280px]"
onImageUpload={uploadImageFile}
/>
</div>
)
}
return (
<RichTextEditor
content={state.content}
onChange={actions.setContent}
className="min-h-[200px]"
onImageUpload={uploadImageFile}
/>
)
}
if (state.noteType === 'markdown' && state.showMarkdownPreview) {
return (
<div
className={cn(
'min-h-[280px] cursor-text prose prose-lg dark:prose-invert max-w-none leading-relaxed',
fullPage ? '' : 'p-3 rounded-md border border-border/40 bg-muted/20'
)}
onClick={() => !readOnly && actions.setShowMarkdownPreview(false)}
>
<MarkdownContent content={state.content} />
{!readOnly && (
<p className="text-[11px] text-foreground/30 mt-8 select-none not-prose italic">
Cliquez pour éditer
</p>
)}
</div>
)
}
if (state.noteType === 'markdown' || state.noteType === 'text') {
if (fullPage) {
return (
<div className="relative">
<textarea
ref={textareaRef}
dir="auto"
placeholder={t('notes.takeNote') || "Commencez à écrire… tapez '/' pour les commandes"}
value={state.content}
onFocus={() => actions.setShowMarkdownPreview(false)}
onChange={(e) => actions.setContent(e.target.value)}
disabled={readOnly}
className="w-full min-h-[280px] border-0 outline-none px-0 bg-transparent editor-body leading-relaxed resize-none overflow-hidden placeholder:text-foreground/30 text-foreground"
/>
{!readOnly && (
<MarkdownSlashCommands
textareaRef={textareaRef as React.RefObject<HTMLTextAreaElement>}
value={state.content}
onChange={(v: string) => actions.setContent(v)}
/>
)}
</div>
)
}
// Dialog mode
return (
<div className="space-y-2">
<Textarea
dir="auto"
placeholder={state.isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
value={state.content}
onChange={(e) => actions.setContent(e.target.value)}
disabled={readOnly}
className={cn(
"min-h-[200px] border-0 focus-visible:ring-0 px-0 bg-transparent resize-none text-sm leading-relaxed",
readOnly && "cursor-default"
)}
/>
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
</div>
)
}
// Checklist mode
if (fullPage) {
return (
<div className="space-y-2">
{state.checkItems.map((item) => (
<div key={item.id} className="flex items-start gap-2 group">
<Checkbox
checked={item.checked}
onCheckedChange={() => actions.handleCheckItem(item.id)}
className="mt-2"
/>
<Input
value={item.text}
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')}
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
/>
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
onClick={() => actions.handleRemoveCheckItem(item.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
<Plus className="h-4 w-4 mr-1" />
{t('notes.addItem')}
</Button>
</div>
)
}
return (
<div className="space-y-2">
{state.checkItems.map((item) => (
<div key={item.id} className="flex items-start gap-2 group">
<Checkbox
checked={item.checked}
onCheckedChange={() => actions.handleCheckItem(item.id)}
className="mt-2"
/>
<Input
value={item.text}
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')}
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
/>
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
onClick={() => actions.handleRemoveCheckItem(item.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
<Plus className="h-4 w-4 mr-1" />
{t('notes.addItem')}
</Button>
</div>
)
}

View File

@@ -0,0 +1,807 @@
'use client'
import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteType, NoteSize } from '@/lib/types'
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { useNotebooks } from '@/context/notebooks-context'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
import { extractImagesFromHTML } from '@/lib/utils'
import { queryKeys } from '@/lib/query-keys'
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
import type { TagSuggestion } from '@/lib/ai/types'
import type { NoteEditorState, NoteEditorActions, NoteEditorContextValue } from './types'
const NoteEditorContext = createContext<NoteEditorContextValue | undefined>(undefined)
interface NoteEditorProviderProps {
note: Note
readOnly?: boolean
fullPage?: boolean
children: ReactNode
}
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, children }: NoteEditorProviderProps) {
const { data: session } = useSession()
const { t } = useLanguage()
const queryClient = useQueryClient()
const { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId, notebooks } = useNotebooks()
const { triggerRefresh } = useNoteRefresh()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
useEffect(() => {
if (session?.user?.id) {
getAISettings(session.user.id).then(settings => {
setAiAssistantEnabled(settings.paragraphRefactor !== false)
setAutoLabelingEnabled(settings.autoLabeling !== false)
}).catch(err => console.error("Failed to fetch AI settings", err))
}
}, [session?.user?.id])
// Core content state
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content)
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [labels, setLabels] = useState<string[]>(note.labels || [])
const [images, setImages] = useState<string[]>(note.images || [])
const [links, setLinks] = useState<LinkMetadata[]>(note.links || [])
const [newLabel, setNewLabel] = useState('')
const [color, setColor] = useState(note.color)
const [size, setSize] = useState<NoteSize>(note.size || 'small')
const [isSaving, setIsSaving] = useState(false)
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
const [noteType, setNoteType] = useState<NoteType>(note.type)
const isMarkdown = noteType === 'markdown'
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
// Refs
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const prevNoteRef = useRef(note)
// CRITICAL: Sync state when note.id changes (lines 101-116 from original)
useEffect(() => {
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
setTitle(note.title || '')
setContent(note.content)
setCheckItems(note.checkItems || [])
setLabels(note.labels || [])
setImages(note.images || [])
setLinks(note.links || [])
setColor(note.color)
setSize(note.size || 'small')
setNoteType(note.type)
setShowMarkdownPreview(note.type === 'markdown')
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
}
prevNoteRef.current = note
}, [note])
// Update context notebookId when note changes
useEffect(() => {
setContextNotebookId(note.notebookId || null)
}, [note.notebookId, setContextNotebookId])
// Auto-tagging hook
const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
content: noteType !== 'checklist' ? content : '',
notebookId: note.notebookId,
enabled: noteType !== 'checklist' && autoLabelingEnabled
})
// Reminder state
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [currentReminder, setCurrentReminder] = useState<Date | null>(
note.reminder ? new Date(note.reminder as unknown as string) : null
)
// Link state
const [showLinkDialog, setShowLinkDialog] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
// Title suggestions state
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
// Reformulation state
const [isReformulating, setIsReformulating] = useState(false)
const [reformulationModal, setReformulationModal] = useState<{
originalText: string
reformulatedText: string
option: string
} | null>(null)
// AI processing state
const [isProcessingAI, setIsProcessingAI] = useState(false)
const [aiOpen, setAiOpen] = useState(false)
const [infoOpen, setInfoOpen] = useState(false)
const [isDirty, setIsDirty] = useState(false)
// fullPage — auto title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
content,
enabled: fullPage && !title && !dismissedTitleSuggestions,
})
// Track previous content for copilot action undo
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
// Memory Echo Connections state
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
// Tags dismissed by the user for this session
const [dismissedTags, setDismissedTags] = useState<string[]>([])
// Filter suggestions to exclude dismissed ones
// and those already present on the note
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
})
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
const uploadImageFile = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', { method: 'POST', body: formData })
if (!response.ok) throw new Error('Upload failed')
const data = await response.json()
return data.url
}
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
for (const file of Array.from(files)) {
try {
const url = await uploadImageFile(file)
setImages(prev => [...prev, url])
} catch (error) {
console.error('Upload error:', error)
toast.error(t('notes.uploadFailed', { filename: file.name }))
}
}
}
// Paste handler: upload clipboard images
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (!file) continue
try {
const url = await uploadImageFile(file)
setImages(prev => [...prev, url])
} catch {
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
}
}
}
}
document.addEventListener('paste', handlePaste, { capture: true })
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
}, [t, noteType])
// Auto-grow textarea as content grows
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
}, [content])
// Also auto-grow when switching FROM preview TO edit mode
useEffect(() => {
if (showMarkdownPreview) return // we're in preview, textarea not mounted
// Defer one frame so the textarea is in the DOM
const raf = requestAnimationFrame(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
el.focus()
})
return () => cancelAnimationFrame(raf)
}, [showMarkdownPreview])
const handleRemoveImage = (index: number) => {
const removedUrl = images[index]
setImages(images.filter((_, i) => i !== index))
// Track removed images for cleanup on save
if (removedUrl) {
setRemovedImageUrls(prev => [...prev, removedUrl])
}
}
const handleAddLink = async () => {
if (!linkUrl) return
setShowLinkDialog(false)
try {
const metadata = await fetchLinkMetadata(linkUrl)
if (metadata) {
setLinks(prev => [...prev, metadata])
toast.success(t('notes.linkAdded'))
} else {
toast.warning(t('notes.linkMetadataFailed'))
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
}
} catch (error) {
console.error('Failed to add link:', error)
toast.error(t('notes.linkAddFailed'))
} finally {
setLinkUrl('')
}
}
const handleRemoveLink = (index: number) => {
setLinks(links.filter((_, i) => i !== index))
}
const allImages = useMemo(() => {
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
return Array.from(new Set([...images, ...extracted]));
}, [images, content, noteType]);
const handleGenerateTitles = async () => {
const fullContentForAI = [
content,
...links.map(l => `${l.title || ''} ${l.description || ''}`)
]
.join(' ')
.trim()
const wordCount = fullContentForAI.split(/\s+/).filter(word => word.length > 0).length
if (wordCount < 10) {
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
return
}
setIsGeneratingTitles(true)
try {
const response = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: fullContentForAI }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || t('ai.titleGenerationError'))
}
const data = await response.json()
setTitleSuggestions(data.suggestions || [])
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
} catch (error: any) {
console.error('Error generating titles:', error)
toast.error(error.message || t('ai.titleGenerationFailed'))
} finally {
setIsGeneratingTitles(false)
}
}
const handleSelectTitle = (title: string) => {
setTitle(title)
setTitleSuggestions([])
toast.success(t('ai.titleApplied'))
}
const handleReformulate = async (option: 'clarify' | 'shorten' | 'improve') => {
const selectedText = window.getSelection()?.toString()
if (!selectedText && (!content || content.trim().length === 0)) {
toast.error(t('ai.reformulationNoText'))
return
}
let textToReformulate: string
if (selectedText && selectedText.trim().split(/\s+/).filter(word => word.length > 0).length >= 10) {
textToReformulate = selectedText
} else {
textToReformulate = content
if (selectedText) {
toast.info(t('ai.reformulationSelectionTooShort'))
}
}
const wordCount = textToReformulate.trim().split(/\s+/).filter(word => word.length > 0).length
if (wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
if (wordCount > 500) {
toast.error(t('ai.reformulationMaxWords'))
return
}
setIsReformulating(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: textToReformulate,
option: option
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || t('ai.reformulationError'))
}
const data = await response.json()
setReformulationModal({
originalText: data.originalText,
reformulatedText: data.reformulatedText,
option: data.option
})
} catch (error: any) {
console.error('Error reformulating:', error)
toast.error(error.message || t('ai.reformulationFailed'))
} finally {
setIsReformulating(false)
}
}
const handleClarifyDirect = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'clarify' })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
setContent(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Clarify error:', error)
toast.error(t('notes.clarifyFailed'))
} finally {
setIsProcessingAI(false)
}
}
const handleShortenDirect = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'shorten' })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
setContent(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Shorten error:', error)
toast.error(t('notes.shortenFailed'))
} finally {
setIsProcessingAI(false)
}
}
const handleImproveDirect = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option: 'improve' })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
setContent(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Improve error:', error)
toast.error(t('notes.improveFailed'))
} finally {
setIsProcessingAI(false)
}
}
const handleTransformMarkdown = async () => {
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (!content || wordCount < 10) {
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
return
}
if (wordCount > 500) {
toast.error(t('ai.reformulationMaxWords'))
return
}
setIsProcessingAI(true)
try {
const response = await fetch('/api/ai/transform-markdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
setContent(data.transformedText)
setNoteType('markdown')
setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess'))
} catch (error) {
console.error('Transform to markdown error:', error)
toast.error(t('ai.transformError'))
} finally {
setIsProcessingAI(false)
}
}
const handleApplyRefactor = () => {
if (!reformulationModal) return
const selectedText = window.getSelection()?.toString()
if (selectedText) {
setContent(reformulationModal.reformulatedText)
} else {
setContent(reformulationModal.reformulatedText)
}
setReformulationModal(null)
toast.success(t('ai.reformulationApplied'))
}
const handleReminderSave = async (date: Date) => {
if (date < new Date()) {
toast.error(t('notes.reminderPastError'))
return
}
setCurrentReminder(date)
try {
await updateNote(note.id, { reminder: date })
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
} catch {
toast.error(t('notebook.savingReminder'))
}
}
const handleRemoveReminder = async () => {
setCurrentReminder(null)
try {
await updateNote(note.id, { reminder: null })
toast.success(t('notes.reminderRemoved'))
} catch {
toast.error(t('notebook.removingReminder'))
}
}
const handleSave = async () => {
setIsSaving(true)
try {
await updateNote(note.id, {
title: title.trim() || null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
labels,
images,
links,
color,
reminder: currentReminder,
isMarkdown: noteType === 'markdown',
type: noteType,
size,
})
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
await refreshLabels()
// Invalidate note and notes list cache
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
// Note: onClose is handled by the composition component
} catch (error) {
console.error('Failed to save note:', error)
} finally {
setIsSaving(false)
}
}
const handleCheckItem = (id: string) => {
setCheckItems(items =>
items.map(item =>
item.id === id ? { ...item, checked: !item.checked } : item
)
)
}
const handleUpdateCheckItem = (id: string, text: string) => {
setCheckItems(items =>
items.map(item => (item.id === id ? { ...item, text } : item))
)
}
const handleAddCheckItem = () => {
setCheckItems([
...checkItems,
{ id: Date.now().toString(), text: '', checked: false },
])
}
const handleRemoveCheckItem = (id: string) => {
setCheckItems(items => items.filter(item => item.id !== id))
}
const handleSelectGhostTag = async (tag: string) => {
const tagExists = labels.some(l => l.toLowerCase() === tag.toLowerCase())
if (!tagExists) {
setLabels(prev => [...prev, tag])
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) {
try {
await addLabel(tag)
} catch (err) {
console.error('Error creating auto-label:', err)
}
}
toast.success(t('ai.tagAdded', { tag }))
}
}
const handleDismissGhostTag = (tag: string) => {
setDismissedTags(prev => [...prev, tag])
}
const handleRemoveLabel = (label: string) => {
setLabels(labels.filter(l => l !== label))
}
const handleMakeCopy = async () => {
try {
const newNote = await createNote({
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
content: content,
color: color,
checkItems: checkItems,
labels: labels,
images: images,
links: links,
isMarkdown: noteType === 'markdown',
type: noteType,
size: size,
})
toast.success(t('notes.copySuccess'))
// Invalidate notes list cache for current notebook
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
// Note: onClose is handled by the composition component
} catch (error) {
console.error('Failed to copy note:', error)
toast.error(t('notes.copyFailed'))
}
}
// Save in place (fullPage) — without closing
const handleSaveInPlace = async () => {
setIsSaving(true)
try {
await updateNote(note.id, {
title: title.trim() || null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
labels,
images,
links,
color,
reminder: currentReminder,
isMarkdown: noteType === 'markdown',
type: noteType,
size,
})
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
await refreshLabels()
// Invalidate note and notes list cache
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
setIsDirty(false)
toast.success('Note sauvegardée !')
} catch (error) {
console.error('Failed to save note:', error)
toast.error('Erreur lors de la sauvegarde.')
} finally {
setIsSaving(false)
}
}
// Ctrl+S / Cmd+S shortcut — save in place in fullPage mode
useEffect(() => {
if (!fullPage) return
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSaveInPlace()
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [fullPage, isSaving])
// Build state object
const state: NoteEditorState = useMemo(() => ({
title,
content,
checkItems,
labels,
images,
links,
newLabel,
color: color as NoteColor,
size,
noteType,
showMarkdownPreview,
removedImageUrls,
isSaving,
isDirty,
isProcessingAI,
aiOpen,
infoOpen,
isGeneratingTitles,
titleSuggestions,
dismissedTitleSuggestions,
isReformulating,
reformulationModal,
previousContentForCopilot,
showReminderDialog,
currentReminder,
showLinkDialog,
linkUrl,
comparisonNotes,
fusionNotes,
dismissedTags,
filteredSuggestions,
isAnalyzingSuggestions,
isMarkdown,
allImages,
colorClasses,
}), [
title, content, checkItems, labels, images, links, newLabel, color, size, noteType,
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
])
// Build actions object
const actions: NoteEditorActions = useMemo(() => ({
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
setDismissedTitleSuggestions,
setContent: (c) => { setContent(c); setIsDirty(true) },
setCheckItems,
handleCheckItem,
handleUpdateCheckItem,
handleAddCheckItem,
handleRemoveCheckItem,
setLabels: (l) => { setLabels(l); setIsDirty(true) },
handleSelectGhostTag,
handleDismissGhostTag,
handleRemoveLabel,
setImages,
handleImageUpload,
handleRemoveImage,
uploadImageFile,
setLinks,
handleAddLink,
handleRemoveLink,
setNoteType: (type) => { setNoteType(type); setShowMarkdownPreview(type === 'markdown'); setIsDirty(true) },
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
setColor: (c) => { setColor(c); setIsDirty(true) },
setSize: (s) => { setSize(s); setIsDirty(true) },
setShowReminderDialog,
setCurrentReminder,
handleReminderSave,
handleRemoveReminder,
setShowLinkDialog,
setLinkUrl,
handleGenerateTitles,
handleSelectTitle,
handleReformulate,
handleApplyRefactor,
handleClarifyDirect,
handleShortenDirect,
handleImproveDirect,
handleTransformMarkdown,
handleSave,
handleSaveInPlace,
handleMakeCopy,
setComparisonNotes,
setFusionNotes,
setReformulationModal,
setIsDirty,
setAiOpen,
setInfoOpen,
setIsProcessingAI,
setIsGeneratingTitles,
setIsAnalyzingSuggestions: (a) => { /* handled by useAutoTagging */ },
setPreviousContentForCopilot,
}), [])
const value: NoteEditorContextValue = useMemo(() => ({
note,
readOnly,
fullPage,
state,
actions,
notebooks: notebooks.map(nb => ({ id: nb.id, name: nb.name })),
globalLabels,
fileInputRef,
textareaRef,
}), [note, readOnly, fullPage, state, actions, notebooks, globalLabels])
return (
<NoteEditorContext.Provider value={value}>
{children}
</NoteEditorContext.Provider>
)
}
export function useNoteEditorContext() {
const context = useContext(NoteEditorContext)
if (context === undefined) {
throw new Error('useNoteEditorContext must be used within a NoteEditorProvider')
}
return context
}

View File

@@ -0,0 +1,345 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { NoteTitleBlock } from './note-title-block'
import { NoteContentArea } from './note-content-area'
import { NoteMetadataSection } from './note-metadata-section'
import { EditorImages } from '@/components/editor-images'
import { ComparisonModal } from '@/components/comparison-modal'
import { FusionModal } from '@/components/fusion-modal'
import { ReminderDialog } from '@/components/reminder-dialog'
import { ContextualAIChat } from '@/components/contextual-ai-chat'
import { EditorConnectionsSection } from '@/components/editor-connections-section'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { Note } from '@/lib/types'
interface NoteEditorDialogProps {
onClose: () => void
}
export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
const { t } = useLanguage()
const handleSaveAndClose = async () => {
await actions.handleSave()
onClose()
}
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent
className={cn(
'!max-w-[min(95vw,1600px)] max-h-[90vh] overflow-hidden p-0 flex flex-row items-stretch rounded-lg',
state.colorClasses
)}
>
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto space-y-4 px-6 py-6">
<DialogHeader>
<DialogTitle className="sr-only">{t('notes.edit')}</DialogTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{readOnly ? t('notes.view') : t('notes.edit')}</h2>
</div>
{readOnly && (
<Badge variant="secondary" className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground">
{t('notes.readOnly')}
</Badge>
)}
</div>
</DialogHeader>
<div className="space-y-4">
{/* Title */}
<NoteTitleBlock />
{/* Title Suggestions */}
{!readOnly && state.titleSuggestions.length > 0 && (
<div>
{/* TitleSuggestions component */}
</div>
)}
{/* Images */}
<EditorImages images={state.images} onRemove={actions.handleRemoveImage} />
{/* Link Previews */}
{state.links.length > 0 && (
<div className="flex flex-col gap-2">
{state.links.map((link, idx) => (
<div key={idx} className="relative group border rounded-lg overflow-hidden bg-white/50 dark:bg-black/20 flex">
{link.imageUrl && (
<div className="w-24 h-24 flex-shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2 flex-1 min-w-0 flex flex-col justify-center">
<h4 className="font-medium text-sm truncate">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 truncate">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary truncate hover:underline block mt-1">
{new URL(link.url).hostname}
</a>
</div>
<Button
variant="ghost"
size="sm"
className="absolute top-1 right-1 h-6 w-6 p-0 bg-white/50 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity rounded-full"
onClick={() => actions.handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* Content Area */}
<NoteContentArea />
{/* Metadata Section */}
<NoteMetadataSection />
{/* Memory Echo Connections Section */}
{!readOnly && (
<EditorConnectionsSection
noteId={note.id}
onOpenNote={(noteId: string) => {
onClose()
window.location.href = `/?note=${noteId}`
}}
onCompareNotes={(noteIds: string[]) => {
Promise.all(noteIds.map(async (id: string) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) {
console.error(`Failed to fetch note ${id}`)
return null
}
const data = await res.json()
if (data.success && data.data) {
return data.data
}
return null
} catch (error) {
console.error(`Error fetching note ${id}:`, error)
return null
}
}))
.then(notes => notes.filter((n: any) => n !== null) as Array<Partial<Note>>)
.then(fetchedNotes => {
actions.setComparisonNotes(fetchedNotes)
})
}}
onMergeNotes={async (noteIds: string[]) => {
const fetchedNotes = await Promise.all(noteIds.map(async (id: string) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) {
console.error(`Failed to fetch note ${id}`)
return null
}
const data = await res.json()
if (data.success && data.data) {
return data.data
}
return null
} catch (error) {
console.error(`Error fetching note ${id}:`, error)
return null
}
}))
actions.setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array<Partial<Note>>)
}}
/>
)}
{/* Dialog Toolbar - inline for now */}
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose}>
{t('general.cancel')}
</Button>
<Button onClick={handleSaveAndClose} disabled={state.isSaving}>
{state.isSaving ? t('notes.saving') : t('general.save')}
</Button>
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={actions.handleImageUpload}
/>
</div>
{/* ── AI Copilot Side Panel ── */}
{state.aiOpen && (
<ContextualAIChat
onClose={() => actions.setAiOpen(false)}
noteTitle={state.title}
noteContent={state.content}
noteImages={state.allImages}
noteId={note.id}
onApplyToNote={(newContent: string) => {
actions.setPreviousContentForCopilot(state.content)
actions.setContent(newContent)
}}
onUndoLastAction={state.previousContentForCopilot !== null ? () => {
if (state.previousContentForCopilot !== null) {
actions.setContent(state.previousContentForCopilot)
}
actions.setPreviousContentForCopilot(null)
} : undefined}
lastActionApplied={state.previousContentForCopilot !== null}
notebooks={notebooks}
/>
)}
</DialogContent>
{/* Reminder Dialog */}
<ReminderDialog
open={state.showReminderDialog}
onOpenChange={actions.setShowReminderDialog}
currentReminder={state.currentReminder}
onSave={actions.handleReminderSave}
onRemove={actions.handleRemoveReminder}
/>
{/* Link Dialog */}
<Dialog open={state.showLinkDialog} onOpenChange={actions.setShowLinkDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('notes.addLink')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder="https://example.com"
value={state.linkUrl}
onChange={(e) => actions.setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
actions.handleAddLink()
}
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => actions.setShowLinkDialog(false)}>
{t('general.cancel')}
</Button>
<Button onClick={actions.handleAddLink}>
{t('general.add')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reformulation Modal */}
{state.reformulationModal && (
<Dialog open={!!state.reformulationModal} onOpenChange={() => actions.setReformulationModal(null)}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('ai.reformulationComparison')}</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div>
<h3 className="font-semibold mb-2 text-sm text-gray-600 dark:text-gray-400">{t('ai.original')}</h3>
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg text-sm">
{state.reformulationModal.originalText}
</div>
</div>
<div>
<h3 className="font-semibold mb-2 text-sm text-purple-600 dark:text-purple-400">
{t('ai.reformulated')} ({state.reformulationModal.option})
</h3>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-sm">
{state.reformulationModal.reformulatedText}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => actions.setReformulationModal(null)}>
{t('general.cancel')}
</Button>
<Button onClick={actions.handleApplyRefactor}>
{t('general.apply')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Comparison Modal */}
{state.comparisonNotes && state.comparisonNotes.length > 0 && (
<ComparisonModal
isOpen={!!state.comparisonNotes}
onClose={() => actions.setComparisonNotes([])}
notes={state.comparisonNotes}
onOpenNote={(noteId: string) => {
onClose()
window.location.href = `/?note=${noteId}`
}}
/>
)}
{/* Fusion Modal */}
{state.fusionNotes && state.fusionNotes.length > 0 && (
<FusionModal
isOpen={!!state.fusionNotes}
onClose={() => actions.setFusionNotes([])}
notes={state.fusionNotes}
onConfirmFusion={async ({ title, content }: { title: string; content: string }, options: { keepAllTags: boolean; archiveOriginals: boolean }) => {
// Save current first
await actions.handleSave()
// Use createNote directly since handleMakeCopy doesn't handle fusion
const { createNote } = await import('@/app/actions/notes')
await createNote({
title,
content,
labels: options.keepAllTags
? [...new Set(state.fusionNotes.flatMap(n => n.labels || []))]
: state.fusionNotes[0].labels || [],
color: state.fusionNotes[0].color,
type: 'text',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
notebookId: state.fusionNotes[0].notebookId ?? undefined
})
// Archive original notes if option is selected
if (options.archiveOriginals) {
const { updateNote } = await import('@/app/actions/notes')
for (const fusionNote of state.fusionNotes) {
if (fusionNote.id) {
await updateNote(fusionNote.id, { isArchived: true })
}
}
}
toast.success(t('toast.notesFusionSuccess'))
onClose()
}}
/>
)}
</Dialog>
)
}

View File

@@ -0,0 +1,146 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { NoteEditorToolbar } from './note-editor-toolbar'
import { NoteTitleBlock } from './note-title-block'
import { NoteContentArea } from './note-content-area'
import { EditorImages } from '@/components/editor-images'
import { ComparisonModal } from '@/components/comparison-modal'
import { FusionModal } from '@/components/fusion-modal'
import { ReminderDialog } from '@/components/reminder-dialog'
import { ContextualAIChat } from '@/components/contextual-ai-chat'
import { NoteDocumentInfoPanel } from '@/components/note-document-info-panel'
import { format } from 'date-fns'
import { toast } from 'sonner'
import { Note } from '@/lib/types'
interface NoteEditorFullPageProps {
onClose: () => void
}
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
return (
<>
{/* ── outer container ── */}
<div className="h-full flex items-stretch overflow-hidden transition-all duration-500">
{/* ── main scrollable column ── */}
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-zinc-950">
{/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
{/* BODY — max-w-4xl, px-12, py-16 */}
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
{/* Breadcrumb + Title block */}
<div className="space-y-4">
{/* Breadcrumb: Notebook Date */}
<div className="flex items-center gap-3 text-[12px] text-foreground/50 uppercase tracking-[.25em] font-bold">
{notebookName && <span>{notebookName}</span>}
{notebookName && <span></span>}
<span suppressHydrationWarning>
{format(new Date(note.contentUpdatedAt), 'MMM d, yyyy')}
</span>
</div>
{/* Title */}
<NoteTitleBlock />
</div>
{/* Hero image — show first note image if present */}
{state.allImages.length > 0 && (
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
<img
src={state.allImages[0]}
alt={state.title}
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500"
/>
</div>
)}
{/* Content area — max-w-3xl for wider reading column */}
<div className="max-w-3xl mx-auto w-full pb-32">
<NoteContentArea />
</div>
</div>
</div>
{/* ── Side panel: AI Chat ── */}
{state.aiOpen && (
<div className="h-full self-stretch bg-background flex flex-col z-50 shrink-0">
<ContextualAIChat
onClose={() => actions.setAiOpen(false)}
noteTitle={state.title}
noteContent={state.content}
noteImages={state.allImages}
noteId={note.id}
onApplyToNote={(nc: string) => {
actions.setPreviousContentForCopilot(state.content)
actions.setContent(nc)
if (state.noteType === 'markdown') actions.setShowMarkdownPreview(true)
}}
onUndoLastAction={state.previousContentForCopilot !== null ? () => { actions.setContent(state.previousContentForCopilot!); actions.setPreviousContentForCopilot(null) } : undefined}
lastActionApplied={state.previousContentForCopilot !== null}
notebooks={notebooks}
diagramInsertFormat={state.noteType === 'richtext' ? 'html' : 'markdown'}
onGenerateTitle={async () => {
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
if (wordCount < 10) {
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
return
}
actions.setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }),
})
if (res.ok) {
const data = await res.json()
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
actions.setTitle(s)
toast.success('Titre généré !')
} else {
toast.error('Impossible de générer un titre.')
}
} else {
toast.error('Erreur lors de la génération du titre.')
}
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
}}
/>
</div>
)}
{/* ── Side panel: Document Info ── */}
{state.infoOpen && (
<div className="w-[400px] h-full self-stretch border-l border-black/10 dark:border-white/10 bg-background flex flex-col z-50 shrink-0">
<NoteDocumentInfoPanel
note={note}
content={state.content}
onClose={() => actions.setInfoOpen(false)}
onNoteRestored={(r: Note) => { actions.setContent(r.content || ''); actions.setTitle(r.title || ''); actions.setIsDirty(false) }}
/>
</div>
)}
</div>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={actions.handleImageUpload} />
<ReminderDialog
open={state.showReminderDialog}
onOpenChange={actions.setShowReminderDialog}
currentReminder={state.currentReminder}
onSave={actions.handleReminderSave}
onRemove={actions.handleRemoveReminder}
/>
</>
)
}

View File

@@ -0,0 +1,307 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { LabelManager } from '@/components/label-manager'
import { LabelBadge } from '@/components/label-badge'
import { GhostTags } from '@/components/ghost-tags'
import { EditorImages } from '@/components/editor-images'
import { TitleSuggestions } from '@/components/title-suggestions'
import { NoteTypeSelector } from '@/components/note-type-selector'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, Info, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut
} from 'lucide-react'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useLanguage } from '@/lib/i18n'
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { format } from 'date-fns'
interface NoteEditorToolbarProps {
mode: 'fullPage' | 'dialog'
onClose: () => void
}
export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
const { t } = useLanguage()
const { triggerRefresh } = useNoteRefresh()
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
if (mode === 'fullPage') {
return (
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-zinc-950/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
{/* Left: back */}
<button
onClick={onClose}
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">Back to collection</span>
</button>
{/* Right: status + type + AI + Info */}
<div className="flex items-center gap-4">
{/* Save status */}
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
{state.isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving</span></>
: state.isDirty
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>Modified</span></>
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
</span>
{/* Note type */}
<NoteTypeSelector
value={state.noteType}
onChange={(newType) => { actions.setNoteType(newType); actions.setIsDirty(true) }}
compact
/>
{/* Preview toggle — only for text/markdown, in toolbar where it's visible */}
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
<button
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
state.showMarkdownPreview
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Eye size={16} />
<span>{state.showMarkdownPreview ? 'Éditer' : 'Aperçu'}</span>
</button>
)}
{/* AI — rounded-full, exact prototype style */}
<button
aria-label="Ouvrir l'assistant IA"
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
state.aiOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Sparkles size={16} />
<span>AI Assistant</span>
</button>
{/* Info — rounded-full */}
<button
aria-label="Informations du document"
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
state.infoOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Info size={16} />
<span>Document Info</span>
</button>
{/* Save button */}
{!readOnly && (
<button
aria-label={state.isDirty ? 'Enregistrer la note' : 'Aucune modification à enregistrer'}
onClick={actions.handleSaveInPlace}
disabled={state.isSaving || !state.isDirty}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300 text-xs font-medium',
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
<span>{state.isSaving ? 'Saving…' : 'Save'}</span>
</button>
)}
{/* Three-dot options menu */}
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label="Menu des options" className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={async () => {
try {
await deleteNote(note.id)
triggerRefresh()
toast.success('Note supprimée.')
onClose()
} catch { toast.error('Impossible de supprimer.') }
}}
className="text-red-600 dark:text-red-400 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer la note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
)
}
// Dialog toolbar
return (
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div className="flex items-center gap-0.5">
{!readOnly && (
<>
{/* Reminder */}
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')}
onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image */}
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link */}
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
<LinkIcon className="h-4 w-4" />
</Button>
<NoteTypeSelector value={state.noteType} onChange={(newType) => { actions.setNoteType(newType); if (newType !== 'markdown') actions.setShowMarkdownPreview(false) }} />
{state.noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
<Eye className="h-4 w-4" />
</Button>
)}
{/* AI Copilot */}
{state.noteType !== 'checklist' && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">IA Note</span>
</Button>
)}
{/* Size Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
<Maximize2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="flex flex-col gap-1 p-1">
{(['small', 'medium', 'large'] as const).map((s) => (
<Button key={s} variant="ghost" size="sm"
onClick={() => actions.setSize(s)}
className={cn('justify-start capitalize', state.size === s && 'bg-accent')}>
{s}
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button key={colorName}
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', classes.bg,
state.color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
onClick={() => actions.setColor(colorName as NoteColor)} title={colorName} />
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Label Manager */}
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
</>
)}
{readOnly && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="text-xs">{t('notes.sharedReadOnly')}</span>
</div>
)}
</div>
<div className="flex gap-2">
{readOnly ? (
<>
<Button
variant="default"
onClick={actions.handleMakeCopy}
className="flex items-center gap-2"
>
<Copy className="h-4 w-4" />
{t('notes.makeCopy')}
</Button>
<Button
variant="ghost"
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={async () => {
try {
await leaveSharedNote(note.id)
toast.success(t('notes.leftShare') || 'Share removed')
triggerRefresh()
onClose()
} catch {
toast.error(t('general.error'))
}
}}
>
<LogOut className="h-4 w-4" />
{t('notes.leaveShare')}
</Button>
<Button variant="ghost" onClick={onClose}>
{t('general.close')}
</Button>
</>
) : (
<>
<Button variant="ghost" onClick={onClose}>
{t('general.cancel')}
</Button>
<Button onClick={actions.handleSave} disabled={state.isSaving}>
{state.isSaving ? t('notes.saving') : t('general.save')}
</Button>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { LabelBadge } from '../label-badge'
import { GhostTags } from '../ghost-tags'
import { cn } from '@/lib/utils'
export function NoteMetadataSection() {
const { state, actions, readOnly } = useNoteEditorContext()
return (
<div className="space-y-4">
{/* Labels */}
{state.labels.length > 0 && (
<div className="flex flex-wrap gap-2">
{state.labels.map((label) => (
<LabelBadge
key={label}
label={label}
onRemove={() => actions.handleRemoveLabel(label)}
/>
))}
</div>
)}
{/* Ghost Tags - only show in dialog mode */}
{!readOnly && state.noteType !== 'richtext' && (
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
)}
{/* Color indicator */}
<div className="flex items-center gap-2">
<span className="text-xs text-foreground/50">Color:</span>
<div className={cn('w-4 h-4 rounded-full', state.colorClasses?.bg || 'bg-gray-100')} />
</div>
{/* Size indicator */}
<div className="flex items-center gap-2">
<span className="text-xs text-foreground/50">Size:</span>
<span className="text-xs capitalize">{state.size}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,118 @@
'use client'
import { useNoteEditorContext } from './note-editor-context'
import { TitleSuggestions } from '@/components/title-suggestions'
import { Loader2, Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
export function NoteTitleBlock() {
const { state, actions, readOnly, fullPage } = useNoteEditorContext()
const { t } = useLanguage()
if (fullPage) {
return (
<div className="space-y-4">
{/* Title — auto-resizing textarea to prevent overflow */}
<div className="group relative">
<textarea
dir="auto"
rows={1}
placeholder={t('notes.titlePlaceholder') || 'Untitled…'}
value={state.title}
onChange={(e) => { actions.setTitle(e.target.value) }}
onInput={(e) => {
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}}
disabled={readOnly}
className={cn(
'w-full text-4xl md:text-5xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight',
'placeholder:text-foreground/20 resize-none overflow-hidden',
!readOnly && 'pr-12'
)}
/>
{/* AI title generation — always visible on hover */}
{!readOnly && (
<button
type="button"
onClick={async () => {
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
if (wordCount < 10) {
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
return
}
actions.setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: plain }),
})
if (res.ok) {
const data = await res.json()
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
actions.setTitle(s)
toast.success('Titre généré !')
} else {
toast.error('Impossible de générer un titre.')
}
} else {
toast.error('Erreur lors de la génération du titre.')
}
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
}}
disabled={state.isProcessingAI}
className="absolute right-0 top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"
title="Générer un titre automatique avec l'IA"
>
{state.isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
</button>
)}
</div>
{/* Auto title suggestions */}
{!state.title && !state.dismissedTitleSuggestions && state.titleSuggestions.length > 0 && (
<TitleSuggestions
suggestions={state.titleSuggestions}
onSelect={(s: string) => { actions.setTitle(s); actions.setDismissedTitleSuggestions(true) }}
onDismiss={() => actions.setDismissedTitleSuggestions(true)}
/>
)}
</div>
)
}
// Dialog mode title block
return (
<div className="relative">
<input
dir="auto"
placeholder={t('notes.titlePlaceholder')}
value={state.title}
onChange={(e) => actions.setTitle(e.target.value)}
disabled={readOnly}
className={cn(
"w-full text-lg font-semibold border-0 focus-visible:ring-0 px-0 bg-transparent pr-10",
readOnly && "cursor-default"
)}
/>
<button
onClick={actions.handleGenerateTitles}
disabled={state.isGeneratingTitles || readOnly}
className="absolute right-0 top-1/2 -translate-y-1/2 p-1 hover:bg-purple-100 dark:hover:bg-purple-900 rounded transition-colors"
title={state.isGeneratingTitles ? t('ai.titleGenerating') : t('ai.titleGenerateWithAI')}
>
{state.isGeneratingTitles ? (
<div className="w-4 h-4 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
) : (
<Sparkles className="w-4 h-4 text-purple-600 hover:text-purple-700 dark:text-purple-400" />
)}
</button>
</div>
)
}

View File

@@ -0,0 +1,174 @@
import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType, LinkMetadata, NoteSize } from '@/lib/types'
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
import type { TagSuggestion } from '@/lib/ai/types'
// State interface - all local state from NoteEditor
export interface NoteEditorState {
// Core content state
title: string
content: string
checkItems: CheckItem[]
labels: string[]
images: string[]
links: LinkMetadata[]
newLabel: string
color: NoteColor
size: NoteSize
noteType: NoteType
// UI state
showMarkdownPreview: boolean
removedImageUrls: string[]
isSaving: boolean
isDirty: boolean
// AI state
isProcessingAI: boolean
aiOpen: boolean
infoOpen: boolean
isGeneratingTitles: boolean
titleSuggestions: TitleSuggestion[]
dismissedTitleSuggestions: boolean
isReformulating: boolean
reformulationModal: {
originalText: string
reformulatedText: string
option: string
} | null
previousContentForCopilot: string | null
// Reminder state
showReminderDialog: boolean
currentReminder: Date | null
// Link dialog state
showLinkDialog: boolean
linkUrl: string
// Memory Echo Connections
comparisonNotes: Array<Partial<Note>>
fusionNotes: Array<Partial<Note>>
// Ghost tags
dismissedTags: string[]
// Tag suggestions (from auto-tagging)
filteredSuggestions: TagSuggestion[]
isAnalyzingSuggestions: boolean
// Context-derived values
isMarkdown: boolean
allImages: string[]
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
}
// Actions interface - all handlers from NoteEditor
export interface NoteEditorActions {
// Title actions
setTitle: (title: string) => void
setDismissedTitleSuggestions: (dismissed: boolean) => void
// Content actions
setContent: (content: string) => void
// CheckItems actions
setCheckItems: (items: CheckItem[]) => void
handleCheckItem: (id: string) => void
handleUpdateCheckItem: (id: string, text: string) => void
handleAddCheckItem: () => void
handleRemoveCheckItem: (id: string) => void
// Labels actions
setLabels: (labels: string[]) => void
handleSelectGhostTag: (tag: string) => void
handleDismissGhostTag: (tag: string) => void
handleRemoveLabel: (label: string) => void
// Images actions
setImages: (images: string[]) => void
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
handleRemoveImage: (index: number) => void
uploadImageFile: (file: File) => Promise<string>
// Links actions
setLinks: (links: LinkMetadata[]) => void
handleAddLink: () => Promise<void>
handleRemoveLink: (index: number) => void
// Note properties
setNoteType: (type: NoteType) => void
setShowMarkdownPreview: (show: boolean) => void
setColor: (color: NoteColor) => void
setSize: (size: NoteSize) => void
// Reminder actions
setShowReminderDialog: (show: boolean) => void
setCurrentReminder: (date: Date | null) => void
handleReminderSave: (date: Date) => Promise<void>
handleRemoveReminder: () => Promise<void>
// Link dialog
setShowLinkDialog: (show: boolean) => void
setLinkUrl: (url: string) => void
// Title suggestions
handleGenerateTitles: () => Promise<void>
handleSelectTitle: (title: string) => void
// Reformulation
handleReformulate: (option: 'clarify' | 'shorten' | 'improve') => Promise<void>
handleApplyRefactor: () => void
// AI Direct handlers
handleClarifyDirect: () => Promise<void>
handleShortenDirect: () => Promise<void>
handleImproveDirect: () => Promise<void>
handleTransformMarkdown: () => Promise<void>
// Save actions
handleSave: () => Promise<void>
handleSaveInPlace: () => Promise<void>
handleMakeCopy: () => Promise<void>
// Memory Echo
setComparisonNotes: (notes: Array<Partial<Note>>) => void
setFusionNotes: (notes: Array<Partial<Note>>) => void
// Modal states
setReformulationModal: (modal: NoteEditorState['reformulationModal']) => void
// State setters
setIsDirty: (dirty: boolean) => void
setAiOpen: (open: boolean) => void
setInfoOpen: (open: boolean) => void
setIsProcessingAI: (processing: boolean) => void
setIsGeneratingTitles: (generating: boolean) => void
setIsAnalyzingSuggestions: (analyzing: boolean) => void
setPreviousContentForCopilot: (content: string | null) => void
}
// Context value - combines state + actions + note reference
export interface NoteEditorContextValue {
// The current note (external source of truth)
note: Note
// Read-only flag
readOnly: boolean
// FullPage flag
fullPage: boolean
// All state
state: NoteEditorState
// All actions
actions: NoteEditorActions
// Computed values from contexts
notebooks: Array<{ id: string; name: string }>
globalLabels: Array<{ name: string }>
// Refs
fileInputRef: React.RefObject<HTMLInputElement | null>
textareaRef: React.RefObject<HTMLTextAreaElement | null>
}

View File

@@ -52,9 +52,8 @@ import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from '@/components/ghost-tags'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { TitleSuggestions } from '@/components/title-suggestions'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useNotebooks } from '@/context/notebooks-context'
import { useRefresh } from '@/lib/use-refresh'
import { ContextualAIChat } from '@/components/contextual-ai-chat'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
@@ -120,9 +119,9 @@ export function NoteInlineEditor({
})
}
}, [session?.user?.id])
const { labels: globalLabels, addLabel } = useLabels()
const { labels: globalLabels, addLabel } = useNotebooks()
const [, startTransition] = useTransition()
const { triggerRefresh } = useNoteRefresh()
const { refreshNotes } = useRefresh()
// ── Local edit state ──────────────────────────────────────────────────────
const [title, setTitle] = useState(note.title || '')
@@ -312,7 +311,7 @@ export function NoteInlineEditor({
}
toast.success(t('toast.notesFusionSuccess'))
setFusionNotes([])
triggerRefresh()
refreshNotes(note?.notebookId)
}
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
@@ -335,7 +334,7 @@ export function NoteInlineEditor({
onArchive?.(note.id)
try {
await toggleArchive(note.id, !note.isArchived)
triggerRefresh()
refreshNotes(note?.notebookId)
} catch {
// Cannot easily revert since onArchive removes from list
toast.error(t('general.error'))
@@ -361,7 +360,7 @@ export function NoteInlineEditor({
startTransition(async () => {
await deleteNote(note.id)
onDelete?.(note.id)
triggerRefresh()
refreshNotes(note?.notebookId)
})
}
@@ -932,6 +931,7 @@ export function NoteInlineEditor({
} : undefined}
lastActionApplied={previousContent !== null}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
diagramInsertFormat={noteType === 'richtext' ? 'html' : 'markdown'}
/>
)}
</div>

View File

@@ -51,7 +51,6 @@ import { GhostTags } from './ghost-tags'
import { TitleSuggestions } from './title-suggestions'
import { CollaboratorDialog } from './collaborator-dialog'
import { AIAssistantActionBar } from './ai-assistant-action-bar'
import { useLabels } from '@/context/LabelContext'
import { useSession } from 'next-auth/react'
import { useSearchParams } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
@@ -106,7 +105,7 @@ export function NoteInput({
forceExpanded = false,
fullWidth = false,
}: NoteInputProps) {
const { labels: globalLabels, addLabel } = useLabels()
const { labels: globalLabels, addLabel } = useNotebooks()
const { data: session } = useSession()
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
@@ -733,7 +732,9 @@ export function NoteInput({
: 'max-w-2xl mx-auto'
return (
<div className={cn(
<div
id="memento-note-composer"
className={cn(
'mb-8 flex flex-row items-stretch transition-all duration-300',
(aiOpen || isExpandedFull) ? 'max-h-[calc(100vh-180px)]' : '',
widthClass
@@ -1079,6 +1080,7 @@ export function NoteInput({
}}
lastActionApplied={false}
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
diagramInsertFormat={type === 'richtext' ? 'html' : 'markdown'}
className="border border-border border-l-0 rounded-r-xl overflow-hidden shadow-sm"
/>
)}

View File

@@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight, GripVertical } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useNotebooks } from '@/context/notebooks-context'
import { useNotebookDrag } from '@/context/notebook-drag-context'
import { useEditorUI } from '@/context/editor-ui-context'
import { Button } from '@/components/ui/button'
import { CreateNotebookDialog } from './create-notebook-dialog'
import { NotebookActions } from './notebook-actions'
@@ -15,7 +15,6 @@ import { DeleteNotebookDialog } from './delete-notebook-dialog'
import { EditNotebookDialog } from './edit-notebook-dialog'
import { NotebookSummaryDialog } from './notebook-summary-dialog'
import { useLanguage } from '@/lib/i18n'
import { useLabels } from '@/context/LabelContext'
import { LabelManagementDialog } from '@/components/label-management-dialog'
import { Notebook } from '@/lib/types'
import { getNotebookIcon } from '@/lib/notebook-icon'
@@ -35,9 +34,8 @@ export function NotebooksList() {
const searchParams = useSearchParams()
const router = useRouter()
const { t, language } = useLanguage()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, isLoading } = useNotebooks()
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
const { labels } = useLabels()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, updateNotebookOrderOptimistic, isLoading, labels } = useNotebooks()
const { draggedNoteId, dragOverNotebookId, dragOver } = useEditorUI()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [editingNotebook, setEditingNotebook] = useState<Notebook | null>(null)
@@ -169,7 +167,7 @@ export function NotebooksList() {
<LabelManagementDialog open={labelsDialogOpen} onOpenChange={setLabelsDialogOpen} />
<div className="flex flex-col pt-1">
{/* Header with Add Button */}
<div className="flex items-center justify-between px-6 py-2 mt-2 group cursor-pointer text-gray-500 hover:text-gray-800 dark:hover:text-gray-300">
<div className="group mt-1 flex cursor-pointer items-center justify-between px-3 py-2 text-muted-foreground hover:text-foreground">
<span className="text-xs font-semibold uppercase tracking-wider">{t('nav.notebooks') || 'NOTEBOOKS'}</span>
<button
onClick={() => setIsCreateDialogOpen(true)}
@@ -218,7 +216,7 @@ export function NotebooksList() {
style={notebook.color ? { backgroundColor: `${notebook.color}20` } : undefined}
>
{/* Header */}
<div className="pointer-events-auto flex items-center justify-between px-6 py-3">
<div className="pointer-events-auto flex items-center justify-between px-3 py-3">
<div className="flex items-center gap-4 min-w-0 flex-1">
<NotebookIcon
className={cn("w-5 h-5 flex-shrink-0 fill-current", !notebook.color && "text-primary dark:text-primary-foreground")}
@@ -304,7 +302,7 @@ export function NotebooksList() {
onDragLeave={handleDragLeave}
className={cn(
"flex items-center relative",
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-e-full me-2"
isDragOver && "ring-2 ring-primary ring-dashed rounded-e-full me-2"
)}
>
<TooltipProvider delayDuration={600}>

View File

@@ -0,0 +1,320 @@
'use client'
import { useState, useTransition, useEffect } from 'react'
import type { Note } from '@/lib/types'
import { getNoteFeedImage, getNotePlainExcerpt, getNoteDisplayTitle } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { useRefresh } from '@/lib/use-refresh'
import { motion, AnimatePresence } from 'motion/react'
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2 } from 'lucide-react'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { deleteNote, toggleArchive, togglePin } from '@/app/actions/notes'
import { toast } from 'sonner'
type NotesEditorialViewProps = {
notes: Note[]
onOpen: (note: Note, readOnly?: boolean) => void
notebookName?: string
onOpenHistory?: (note: Note) => void
}
function formatNoteDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).toUpperCase()
}
function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
note: Note
onOpen: (note: Note) => void
onOpenHistory?: (note: Note) => void
}) {
const { t } = useLanguage()
const { refreshNotes } = useRefresh()
const [, startTransition] = useTransition()
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
startTransition(async () => {
try {
await deleteNote(note.id)
refreshNotes(note?.notebookId)
toast.success(t('notes.deleted') || 'Note supprimée')
} catch {
toast.error(t('general.error'))
}
})
}
const handleArchive = (e: React.MouseEvent) => {
e.stopPropagation()
startTransition(async () => {
try {
await toggleArchive(note.id, !note.isArchived)
refreshNotes(note?.notebookId)
toast.success(note.isArchived ? (t('notes.unarchived') || 'Désarchivée') : (t('notes.archived') || 'Archivée'))
} catch {
toast.error(t('general.error'))
}
})
}
const handlePin = (e: React.MouseEvent) => {
e.stopPropagation()
startTransition(async () => {
try {
await togglePin(note.id, !note.isPinned)
refreshNotes(note?.notebookId)
} catch {
toast.error(t('general.error'))
}
})
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
<button className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-muted/60 text-muted-foreground hover:text-foreground">
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={e => { e.stopPropagation(); onOpen(note) }}>
<Pencil className="h-4 w-4 mr-2" />
{t('notes.open') || 'Ouvrir'}
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePin}>
<Pin className="h-4 w-4 mr-2" />
{note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchive}>
<Archive className="h-4 w-4 mr-2" />
{note.isArchived ? (t('notes.unarchive') || 'Désarchiver') : (t('notes.archive') || 'Archiver')}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem onClick={e => { e.stopPropagation(); onOpenHistory(note) }}>
<History className="h-4 w-4 mr-2" />
{t('notes.history') || 'Historique'}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} className="text-red-600 dark:text-red-400 focus:text-red-600">
<Trash2 className="h-4 w-4 mr-2" />
{t('notes.delete') || 'Supprimer'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
/** Deterministic hue from a string — consistent per note */
function stringToHue(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff
return h % 360
}
function EditorialThumbnail({
note,
title,
aiIllustrationEnabled,
}: {
note: Note
title: string
aiIllustrationEnabled: boolean
}) {
const { t } = useLanguage()
const { refreshNotes } = useRefresh()
const [busy, setBusy] = useState(false)
const img = getNoteFeedImage(note)
const handleGenerateSvg = async (e: React.MouseEvent) => {
e.stopPropagation()
if (!aiIllustrationEnabled || busy || img) return
setBusy(true)
try {
const res = await generateNoteIllustrationSvg(note.id)
if (!res.ok) {
toast.error(res.error)
} else {
toast.success(t('notes.illustrationGenerated') || 'Illustration générée')
refreshNotes(note?.notebookId)
}
} finally {
setBusy(false)
}
}
return (
<div className="relative w-full md:w-56 aspect-[4/3] bg-card/80 border border-border overflow-hidden rounded shadow-sm flex-shrink-0 group/thumb">
{img ? (
<img
src={img}
alt=""
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
/>
) : note.illustrationSvg ? (
<div
className="w-full h-full flex items-center justify-center bg-muted/30 p-2 [&_svg]:max-w-full [&_svg]:max-h-full [&_svg]:w-auto [&_svg]:h-auto"
// SVG déjà sanitisé côté serveur (note-illustration.ts)
dangerouslySetInnerHTML={{ __html: note.illustrationSvg }}
aria-hidden
/>
) : (
<>
<NoteThumbnailPlaceholder title={title} noteId={note.id} />
{aiIllustrationEnabled && (
<button
type="button"
aria-label={t('notes.generateIllustration') || 'Générer une illustration IA'}
title={t('notes.generateIllustration') || 'Générer une illustration IA'}
className="absolute bottom-2 right-2 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent z-10 opacity-0 group-hover/thumb:opacity-100 md:opacity-100 focus-visible:opacity-100"
onClick={handleGenerateSvg}
disabled={busy}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4 text-primary" />}
</button>
)}
</>
)}
</div>
)
}
/** SVG thumbnail for notes without an image */
function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: string }) {
// Try to extract the first emoji from the title
const emojiMatch = title.match(/\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u)
const emoji = emojiMatch?.[0]
const letter = title.replace(/\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu, '').trim()[0]?.toUpperCase() || '?'
const hue = stringToHue(noteId)
return (
<div
className="h-full w-full flex items-center justify-center relative overflow-hidden"
style={{ background: `linear-gradient(145deg, hsl(${hue} 25% 94%) 0%, hsl(${hue} 18% 87%) 100%)` }}
>
{/* Decorative concentric circles */}
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 224 168"
fill="none"
aria-hidden
style={{ color: `hsl(${hue} 30% 60%)` }}
>
<circle cx="112" cy="84" r="90" stroke="currentColor" strokeWidth="0.6" opacity="0.25" />
<circle cx="112" cy="84" r="64" stroke="currentColor" strokeWidth="0.6" opacity="0.2" />
<circle cx="112" cy="84" r="38" stroke="currentColor" strokeWidth="0.6" opacity="0.15" />
<line x1="22" y1="84" x2="202" y2="84" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
<line x1="112" y1="4" x2="112" y2="164" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
</svg>
{emoji ? (
<span
className="relative text-5xl leading-none select-none"
style={{ filter: `drop-shadow(0 2px 8px hsl(${hue} 40% 40% / 0.2))` }}
>
{emoji}
</span>
) : (
<span
className="relative font-memento-serif font-bold select-none leading-none"
style={{
fontSize: '4.5rem',
color: `hsl(${hue} 35% 35%)`,
opacity: 0.35,
}}
>
{letter}
</span>
)}
</div>
)
}
export function NotesEditorialView({
notes,
onOpen,
notebookName,
onOpenHistory,
}: NotesEditorialViewProps) {
const { t } = useLanguage()
const { data: session } = useSession()
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
useEffect(() => {
if (!session?.user?.id) {
setAiIllustrationEnabled(false)
return
}
getAISettings(session.user.id)
.then((s) => setAiIllustrationEnabled(s.paragraphRefactor !== false))
.catch(() => setAiIllustrationEnabled(false))
}, [session?.user?.id])
return (
<div className="mx-auto w-full max-w-3xl space-y-16">
<AnimatePresence>
{notes.map((note: Note, index: number) => {
const title = getNoteDisplayTitle(note, t('notes.untitled') || 'Untitled')
const excerpt = getNotePlainExcerpt(note)
const dateStr = formatNoteDate(note.createdAt)
return (
<motion.article
key={note.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 * index, duration: 0.6 }}
className="space-y-4 group cursor-pointer relative border-b border-border/20 pb-16"
onClick={() => onOpen(note)}
>
{/* Date / breadcrumb + actions menu */}
<div className="flex items-center justify-between">
<div className="note-date-badge">
{notebookName ? `${notebookName}${dateStr}` : dateStr}
</div>
<EditorialNoteMenu note={note} onOpen={onOpen} onOpenHistory={onOpenHistory} />
</div>
<h2 className="font-memento-serif text-2xl font-medium text-foreground flex items-center justify-between">
{title}
<span className="opacity-0 group-hover:opacity-30 transition-opacity shrink-0">
<ChevronRight size={20} />
</span>
</h2>
<div className="flex flex-col md:flex-row gap-8 items-start">
<EditorialThumbnail note={note} title={title} aiIllustrationEnabled={aiIllustrationEnabled} />
<div className="space-y-3 flex-1">
{excerpt ? (
<p className="text-[14px] leading-relaxed text-foreground/80 font-light max-w-lg line-clamp-4">
{excerpt}
</p>
) : (
<p className="text-sm italic text-muted-foreground/50">{t('notes.noContent')}</p>
)}
<span className="text-[11px] text-muted-foreground uppercase tracking-widest font-medium inline-flex items-center gap-1 group-hover:gap-2 transition-all">
{t('notes.readMore') || 'Read more'}
<ChevronRight size={10} />
</span>
</div>
</div>
</motion.article>
)
})}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,166 @@
'use client'
import { useMemo } from 'react'
import { Note } from '@/lib/types'
import { cn } from '@/lib/utils'
import { formatDistanceToNow, Locale } from 'date-fns'
import { enUS } from 'date-fns/locale/en-US'
import { fr } from 'date-fns/locale/fr'
import { useLanguage } from '@/lib/i18n'
import { Users, FileText, ImageIcon } from 'lucide-react'
import { useSession } from 'next-auth/react'
const localeMap: Record<string, Locale> = {
en: enUS,
fr,
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}
function firstImageFromContent(html: string): string | null {
const m = html.match(/<img[^>]+src=["']([^"']+)["']/i)
return m ? m[1] : null
}
function previewText(note: Note): string {
if (note.type === 'richtext') {
return stripHtml(note.content || '').slice(0, 280)
}
if (note.type === 'markdown') {
return (note.content || '')
.replace(/^#{1,6}\s+/gm, '')
.replace(/[*`_~]/g, '')
.replace(/\[(.*?)\]\([^)]*\)/g, '$1')
.slice(0, 280)
}
if (note.type === 'checklist') {
const items = (note.checkItems || []).map((i) => i.text).join(' · ')
return items.slice(0, 280)
}
return (note.content || '').slice(0, 280)
}
function thumbUrl(note: Note): string | null {
if (note.images?.length) return note.images[0]!
if (note.type === 'richtext' && note.content) {
return firstImageFromContent(note.content)
}
return null
}
interface NotesListViewProps {
notes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
}
export function NotesListView({
notes,
onEdit,
}: NotesListViewProps) {
const { t, language } = useLanguage()
const { data: session } = useSession()
const currentUserId = session?.user?.id
const locale = localeMap[language] || enUS
const sorted = useMemo(
() =>
[...notes].sort(
(a, b) =>
new Date(b.contentUpdatedAt || b.updatedAt).getTime() -
new Date(a.contentUpdatedAt || a.updatedAt).getTime()
),
[notes]
)
if (sorted.length === 0) {
return (
<p className="py-12 text-center text-sm text-muted-foreground" data-testid="notes-list-empty">
{t('notes.emptyState')}
</p>
)
}
return (
<div className="flex flex-col gap-3" data-testid="notes-list-view">
{sorted.map((note) => {
const thumb = thumbUrl(note)
const preview = previewText(note)
const title = note.title?.trim() || t('notes.untitled')
const edited = new Date(note.contentUpdatedAt || note.updatedAt)
const sharedCount = note.sharedWith?.length ?? 0
const isShared = sharedCount > 0 || (note as any)._isShared
const isSharedNote = !!(currentUserId && note.userId && currentUserId !== note.userId)
return (
<button
key={note.id}
type="button"
onClick={() => onEdit?.(note, !!isSharedNote)}
className={cn(
'group flex w-full gap-5 rounded-2xl border border-border/50 bg-card/90 p-4 text-start shadow-sm transition-all duration-200',
'hover:border-primary/25 hover:shadow-[0_8px_30px_-12px_color-mix(in_oklab, var(--foreground) 14%, transparent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
'md:gap-6 md:p-5'
)}
>
<div
className="relative size-[4.5rem] shrink-0 overflow-hidden rounded-xl border border-border/40 bg-muted/40 md:size-24"
aria-hidden
>
{thumb ? (
<img src={thumb} alt="" className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground/50">
<FileText className="size-8 stroke-[1.25]" />
</div>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-baseline justify-between gap-2 gap-y-1">
<h3 className="font-memento-serif text-lg font-normal leading-snug tracking-tight text-foreground md:text-xl">
{title}
</h3>
<time
dateTime={edited.toISOString()}
className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-muted-foreground tabular-nums"
>
{formatDistanceToNow(edited, { addSuffix: true, locale })}
</time>
</div>
{preview ? (
<p className="line-clamp-2 text-sm leading-relaxed text-muted-foreground md:line-clamp-3">{preview}</p>
) : null}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
{(note.labels?.length ?? 0) > 0 && (
<div className="flex flex-wrap gap-1.5">
{note.labels!.slice(0, 6).map((label) => (
<span
key={label}
className="rounded-md border border-border/60 bg-muted/30 px-2 py-0.5 text-[11px] font-medium text-foreground/80"
>
{label}
</span>
))}
{note.labels!.length > 6 && (
<span className="text-[11px] text-muted-foreground">+{note.labels!.length - 6}</span>
)}
</div>
)}
{isShared && (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-primary">
<Users className="size-3.5 opacity-80" />
{sharedCount > 0 ? `${sharedCount}` : t('notes.sharedShort') || 'Partagé'}
</span>
)}
</div>
</div>
</button>
)
})}
</div>
)
}

View File

@@ -3,6 +3,7 @@
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { NotesTabsView } from '@/components/notes-tabs-view'
import { NotesListView } from '@/components/notes-list-view'
const MasonryGridLazy = dynamic(
() => import('@/components/masonry-grid').then((m) => m.MasonryGrid),
@@ -17,7 +18,7 @@ const MasonryGridLazy = dynamic(
}
)
export type NotesViewMode = 'masonry' | 'tabs'
export type NotesViewMode = 'masonry' | 'tabs' | 'list'
interface NotesMainSectionProps {
notes: Note[]
@@ -42,6 +43,14 @@ export function NotesMainSection({
onEnableHistory,
onNoteCreated,
}: NotesMainSectionProps) {
if (viewMode === 'list') {
return (
<div data-testid="notes-list">
<NotesListView notes={notes} onEdit={onEdit} />
</div>
)
}
if (viewMode === 'tabs') {
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">

View File

@@ -0,0 +1,69 @@
'use client'
import type { Note } from '@/lib/types'
import { getNoteFeedImage, getNotePlainExcerpt } from '@/lib/note-preview'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
type NotesNotebookFeedProps = {
notes: Note[]
onOpen: (note: Note, readOnly?: boolean) => void
}
export function NotesNotebookFeed({ notes, onOpen }: NotesNotebookFeedProps) {
const { t } = useLanguage()
return (
<div className="mx-auto w-full max-w-3xl">
{notes.map((note) => {
const title = note.title?.trim() || t('notes.untitled')
const img = getNoteFeedImage(note)
const excerpt = getNotePlainExcerpt(note)
return (
<article
key={note.id}
className="mb-16 border-b border-foreground/10 pb-16 last:mb-0 last:border-0 last:pb-0"
>
<h2 className="font-memento-serif text-2xl font-normal tracking-tight text-foreground md:text-[1.65rem]">
{title}
</h2>
<button
type="button"
className="mt-7 w-full text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm"
onClick={() => onOpen(note)}
>
<div className="flex flex-col gap-8 md:flex-row md:items-start">
<div
className={cn(
'relative w-full shrink-0 overflow-hidden rounded-lg bg-muted/40 md:w-[clamp(11rem,32%,15rem)]',
'aspect-[4/3]'
)}
>
{img ? (
// eslint-disable-next-line @next/next/no-img-element -- note content may be any uploaded or external URL
<img src={img} alt="" className="h-full w-full object-cover" />
) : (
<div className="h-full w-full bg-muted/30" aria-hidden />
)}
</div>
<div className="min-w-0 flex-1 space-y-4 pt-0 md:pt-1">
{excerpt ? (
<p className="text-sm leading-relaxed text-foreground/85 line-clamp-5">
{excerpt}
</p>
) : (
<p className="text-sm italic text-muted-foreground">{t('notes.noContent')}</p>
)}
<span className="inline-block text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
{t('notes.readMore')}
</span>
</div>
</div>
</button>
</article>
)
})}
</div>
)
}

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState, useTransition, useRef } from 'react'
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
import { useRefresh } from '@/lib/use-refresh'
import {
DndContext,
type DragEndEvent,
@@ -104,7 +105,7 @@ const COLOR_ACCENT: Record<NoteColor, string> = {
yellow: 'bg-amber-400',
green: 'bg-emerald-400',
teal: 'bg-teal-400',
blue: 'bg-sky-400',
blue: 'bg-slate-400',
purple: 'bg-violet-400',
pink: 'bg-fuchsia-400',
gray: 'bg-gray-400',
@@ -118,7 +119,7 @@ const COLOR_PANEL_BG: Record<NoteColor, string> = {
yellow: 'from-amber-50/60 dark:from-amber-950/20 to-background',
green: 'from-emerald-50/60 dark:from-emerald-950/20 to-background',
teal: 'from-teal-50/60 dark:from-teal-950/20 to-background',
blue: 'from-sky-50/60 dark:from-sky-950/20 to-background',
blue: 'from-slate-50/60 dark:from-slate-950/20 to-background',
purple: 'from-violet-50/60 dark:from-violet-950/20 to-background',
pink: 'from-fuchsia-50/60 dark:from-fuchsia-950/20 to-background',
gray: 'from-gray-50/60 dark:from-gray-900/20 to-background',
@@ -131,7 +132,7 @@ const COLOR_ICON: Record<NoteColor, string> = {
yellow: 'text-amber-500',
green: 'text-emerald-500',
teal: 'text-teal-500',
blue: 'text-sky-500',
blue: 'text-slate-600',
purple: 'text-violet-500',
pink: 'text-fuchsia-500',
gray: 'text-gray-500',
@@ -368,13 +369,13 @@ function SidebarActionBtn({
"group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium transition-all duration-150",
disabled
? "cursor-not-allowed text-muted-foreground/60 opacity-70"
: "text-foreground/70 hover:bg-sky-50 hover:text-sky-700"
: "text-foreground/70 hover:bg-stone-100/80 hover:text-stone-800 dark:hover:bg-white/5 dark:hover:text-stone-100"
)}
>
<span
className={cn(
"transition-colors",
disabled ? "text-muted-foreground/60" : "text-muted-foreground group-hover:text-sky-600"
disabled ? "text-muted-foreground/60" : "text-muted-foreground group-hover:text-stone-600 dark:group-hover:text-stone-300"
)}
>
{icon}
@@ -519,9 +520,9 @@ function NoteMetaSidebar({
<PopoverTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium text-foreground/70 hover:bg-sky-50 hover:text-sky-700 transition-all duration-150"
className="group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium text-foreground/70 hover:bg-stone-100/80 hover:text-stone-800 dark:hover:bg-white/5 dark:hover:text-stone-100 transition-all duration-150"
>
<span className="text-muted-foreground group-hover:text-sky-600 transition-colors">
<span className="text-muted-foreground group-hover:text-stone-600 dark:group-hover:text-stone-300 transition-colors">
{isMoving
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <FolderInput className="h-3.5 w-3.5" />}
@@ -635,7 +636,7 @@ export function NotesTabsView({
onNoteCreated,
}: NotesTabsViewProps) {
const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefreshOptional()
const { refreshNotes } = useRefresh()
const [items, setItems] = useState<Note[]>(notes)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isCreating, startCreating] = useTransition()
@@ -796,8 +797,8 @@ export function NotesTabsView({
})
setSelectedId(newNote.id)
onNoteCreated?.(newNote)
// NOTE: No triggerRefresh() here — the note is already added to items above.
// triggerRefresh() would call getAllNotes() which may return stale cache
// NOTE: No refreshNotes(note.notebookId) here — the note is already added to items above.
// refreshNotes(note.notebookId) would call getAllNotes() which may return stale cache
// in production (skipRevalidation:true skips cache invalidation).
} catch {
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
@@ -810,7 +811,7 @@ export function NotesTabsView({
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n))
try {
await updateNote(note.id, { isPinned: next }, { skipRevalidation: true })
triggerRefresh()
refreshNotes(note.notebookId)
toast.success(next ? (t('notes.pinned') || 'Épinglée') : (t('notes.unpinned') || 'Désépinglée'))
} catch {
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: note.isPinned } : n))
@@ -823,7 +824,7 @@ export function NotesTabsView({
await toggleArchive(note.id, true)
setItems((prev) => prev.filter((n) => n.id !== note.id))
setSelectedId((prev) => (prev === note.id ? null : prev))
triggerRefresh()
refreshNotes(note.notebookId)
toast.success(t('notes.archived') || 'Note archivée')
} catch {
toast.error(t('notes.archiveFailed') || 'Archivage échoué')
@@ -1022,7 +1023,7 @@ export function NotesTabsView({
onArchive={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
triggerRefresh()
refreshNotes(selected?.notebookId)
}}
/>
{/* Toggle sidebar button — top-right of editor, always visible */}
@@ -1057,7 +1058,7 @@ export function NotesTabsView({
} else {
toast.info(t('reminder.removeReminder'))
}
triggerRefresh()
refreshNotes(items.find(n => n.id === noteId)?.notebookId)
} catch {
toast.error(t('general.error'))
}
@@ -1110,7 +1111,7 @@ export function NotesTabsView({
setItems((prev) => prev.filter((n) => n.id !== noteToDelete.id))
setSelectedId((prev) => (prev === noteToDelete.id ? null : prev))
setNoteToDelete(null)
triggerRefresh()
refreshNotes(noteToDelete.notebookId)
toast.success(t('notes.deleted'))
} catch {
toast.error(t('notes.deleteFailed'))

View File

@@ -1,7 +1,7 @@
'use client'
import { useTransition } from 'react'
import { LayoutGrid, PanelsTopLeft } from 'lucide-react'
import { LayoutGrid, PanelsTopLeft, List } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@@ -63,6 +63,26 @@ export function NotesViewToggle({ mode, onModeChange, className }: NotesViewTogg
</TooltipTrigger>
<TooltipContent side="bottom">{t('notes.viewCardsTooltip')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
className={cn(
'h-9 rounded-full px-3 gap-1.5',
mode === 'list' && 'bg-background shadow-sm text-foreground'
)}
onClick={() => setMode('list')}
aria-pressed={mode === 'list'}
>
<List className="h-4 w-4" aria-hidden />
<span className="hidden sm:inline text-xs font-medium">{t('notes.viewList')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('notes.viewListTooltip')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button

Some files were not shown because too many files have changed in this diff Show More