feat: architectural grid editor fullPage + slash commands + doc info panel + AI title
This commit is contained in:
9
architectural-grid (2)/.env.example
Normal file
9
architectural-grid (2)/.env.example
Normal 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
8
architectural-grid (2)/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
architectural-grid (2)/README.md
Normal file
20
architectural-grid (2)/README.md
Normal 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`
|
||||||
13
architectural-grid (2)/index.html
Normal file
13
architectural-grid (2)/index.html
Normal 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>
|
||||||
|
|
||||||
6
architectural-grid (2)/metadata.json
Normal file
6
architectural-grid (2)/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Architectural Grid",
|
||||||
|
"description": "A minimalist notebook for architectural research and conceptual sketches.",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": []
|
||||||
|
}
|
||||||
34
architectural-grid (2)/package.json
Normal file
34
architectural-grid (2)/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
579
architectural-grid (2)/src/App.tsx
Normal file
579
architectural-grid (2)/src/App.tsx
Normal 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">
|
||||||
|
© 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
52
architectural-grid (2)/src/index.css
Normal file
52
architectural-grid (2)/src/index.css
Normal 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);
|
||||||
|
}
|
||||||
10
architectural-grid (2)/src/main.tsx
Normal file
10
architectural-grid (2)/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
26
architectural-grid (2)/tsconfig.json
Normal file
26
architectural-grid (2)/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
24
architectural-grid (2)/vite.config.ts
Normal file
24
architectural-grid (2)/vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
9
architectural-grid (3)/.env.example
Normal file
9
architectural-grid (3)/.env.example
Normal 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
8
architectural-grid (3)/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
architectural-grid (3)/README.md
Normal file
20
architectural-grid (3)/README.md
Normal 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`
|
||||||
13
architectural-grid (3)/index.html
Normal file
13
architectural-grid (3)/index.html
Normal 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>
|
||||||
|
|
||||||
6
architectural-grid (3)/metadata.json
Normal file
6
architectural-grid (3)/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Architectural Grid",
|
||||||
|
"description": "A minimalist notebook for architectural research and conceptual sketches.",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": []
|
||||||
|
}
|
||||||
0
architectural-grid (3)/package-lock.json
generated
Normal file
0
architectural-grid (3)/package-lock.json
generated
Normal file
34
architectural-grid (3)/package.json
Normal file
34
architectural-grid (3)/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
936
architectural-grid (3)/src/App.tsx
Normal file
936
architectural-grid (3)/src/App.tsx
Normal 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">
|
||||||
|
© 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
58
architectural-grid (3)/src/index.css
Normal file
58
architectural-grid (3)/src/index.css
Normal 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);
|
||||||
|
}
|
||||||
10
architectural-grid (3)/src/main.tsx
Normal file
10
architectural-grid (3)/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
26
architectural-grid (3)/tsconfig.json
Normal file
26
architectural-grid (3)/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
24
architectural-grid (3)/vite.config.ts
Normal file
24
architectural-grid (3)/vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
9
architectural-grid (4)/.env.example
Normal file
9
architectural-grid (4)/.env.example
Normal 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
8
architectural-grid (4)/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
architectural-grid (4)/README.md
Normal file
20
architectural-grid (4)/README.md
Normal 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`
|
||||||
13
architectural-grid (4)/index.html
Normal file
13
architectural-grid (4)/index.html
Normal 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>
|
||||||
|
|
||||||
6
architectural-grid (4)/metadata.json
Normal file
6
architectural-grid (4)/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Architectural Grid",
|
||||||
|
"description": "A minimalist notebook for architectural research and conceptual sketches.",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": []
|
||||||
|
}
|
||||||
34
architectural-grid (4)/package.json
Normal file
34
architectural-grid (4)/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1305
architectural-grid (4)/src/App.tsx
Normal file
1305
architectural-grid (4)/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
58
architectural-grid (4)/src/index.css
Normal file
58
architectural-grid (4)/src/index.css
Normal 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);
|
||||||
|
}
|
||||||
10
architectural-grid (4)/src/main.tsx
Normal file
10
architectural-grid (4)/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
26
architectural-grid (4)/tsconfig.json
Normal file
26
architectural-grid (4)/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
24
architectural-grid (4)/vite.config.ts
Normal file
24
architectural-grid (4)/vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
9
architectural-grid1/.env.example
Normal file
9
architectural-grid1/.env.example
Normal 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
8
architectural-grid1/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
architectural-grid1/README.md
Normal file
20
architectural-grid1/README.md
Normal 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`
|
||||||
13
architectural-grid1/index.html
Normal file
13
architectural-grid1/index.html
Normal 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>
|
||||||
|
|
||||||
6
architectural-grid1/metadata.json
Normal file
6
architectural-grid1/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Architectural Grid",
|
||||||
|
"description": "A minimalist notebook for architectural research and conceptual sketches.",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": []
|
||||||
|
}
|
||||||
34
architectural-grid1/package.json
Normal file
34
architectural-grid1/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
408
architectural-grid1/src/App.tsx
Normal file
408
architectural-grid1/src/App.tsx
Normal 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">
|
||||||
|
© 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
architectural-grid1/src/index.css
Normal file
34
architectural-grid1/src/index.css
Normal 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);
|
||||||
|
}
|
||||||
10
architectural-grid1/src/main.tsx
Normal file
10
architectural-grid1/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
26
architectural-grid1/tsconfig.json
Normal file
26
architectural-grid1/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
24
architectural-grid1/vite.config.ts
Normal file
24
architectural-grid1/vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
60
memento-note/.win11-prompt.txt
Normal file
60
memento-note/.win11-prompt.txt
Normal 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.
|
||||||
@@ -159,6 +159,7 @@ export function AgentsPageClient({
|
|||||||
role: formData.get('role') as string,
|
role: formData.get('role') as string,
|
||||||
sourceUrls: formData.get('sourceUrls') ? JSON.parse(formData.get('sourceUrls') as string) : undefined,
|
sourceUrls: formData.get('sourceUrls') ? JSON.parse(formData.get('sourceUrls') as string) : undefined,
|
||||||
sourceNotebookId: (formData.get('sourceNotebookId') 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,
|
targetNotebookId: (formData.get('targetNotebookId') as string) || undefined,
|
||||||
frequency: formData.get('frequency') as string,
|
frequency: formData.get('frequency') as string,
|
||||||
tools: formData.get('tools') ? JSON.parse(formData.get('tools') as string) : undefined,
|
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,
|
scheduledTime: (formData.get('scheduledTime') as string) || undefined,
|
||||||
scheduledDay: formData.get('scheduledDay') ? Number(formData.get('scheduledDay')) : undefined,
|
scheduledDay: formData.get('scheduledDay') ? Number(formData.get('scheduledDay')) : undefined,
|
||||||
timezone: (formData.get('timezone') as string) || undefined,
|
timezone: (formData.get('timezone') as string) || undefined,
|
||||||
|
slideTheme: (formData.get('slideTheme') as string) || undefined,
|
||||||
|
slideStyle: (formData.get('slideStyle') as string) || undefined,
|
||||||
}
|
}
|
||||||
if (editingAgent) {
|
if (editingAgent) {
|
||||||
await updateAgent(editingAgent.id, data)
|
await updateAgent(editingAgent.id, data)
|
||||||
@@ -196,70 +199,30 @@ export function AgentsPageClient({
|
|||||||
const existingAgentNames = useMemo(() => agents.map(a => a.name), [agents])
|
const existingAgentNames = useMemo(() => agents.map(a => a.name), [agents])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* Full-bleed layout: -m-4 cancels the p-4 of the parent <main> */
|
/* Full-bleed layout */
|
||||||
<div className="flex -m-4 h-[calc(100vh-4rem)] overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
|
||||||
{/* ── LEFT SIDEBAR ── */}
|
{/* ── Top header bar — architectural grid style ── */}
|
||||||
<aside className="w-60 flex-shrink-0 flex flex-col bg-muted/30 border-r border-border/40 h-full font-display">
|
<header className="flex items-center justify-between px-12 py-10 border-b border-border/40 flex-shrink-0">
|
||||||
{/* Brand */}
|
<div>
|
||||||
<div className="flex items-center gap-3 px-5 py-5 border-b border-border/40">
|
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight">
|
||||||
<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" />
|
|
||||||
{t('agents.myAgents')}
|
{t('agents.myAgents')}
|
||||||
</button>
|
</h1>
|
||||||
</nav>
|
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">
|
||||||
|
{t('agents.subtitle')}
|
||||||
{/* Footer: Help */}
|
</p>
|
||||||
<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>
|
|
||||||
</div>
|
</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 ── */}
|
{/* ── Scrollable content area ── */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 bg-background overflow-hidden">
|
<main className="flex-1 overflow-y-auto px-12 py-10">
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
|
|
||||||
{/* Dashboard tab - agents + templates */}
|
{/* Dashboard tab - agents + templates */}
|
||||||
{activeTab === 'dashboard' && (
|
{activeTab === 'dashboard' && (
|
||||||
@@ -329,7 +292,6 @@ export function AgentsPageClient({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sliding panels */}
|
{/* Sliding panels */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { HeaderWrapper } from "@/components/header-wrapper";
|
|
||||||
import { Sidebar } from "@/components/sidebar";
|
import { Sidebar } from "@/components/sidebar";
|
||||||
import { ProvidersWrapper } from "@/components/providers-wrapper";
|
import { ProvidersWrapper } from "@/components/providers-wrapper";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
@@ -7,28 +6,23 @@ import { headers } from "next/headers";
|
|||||||
import { detectUserLanguage, parseAcceptLanguage } from "@/lib/i18n/detect-user-language";
|
import { detectUserLanguage, parseAcceptLanguage } from "@/lib/i18n/detect-user-language";
|
||||||
import { loadTranslations } from "@/lib/i18n/load-translations";
|
import { loadTranslations } from "@/lib/i18n/load-translations";
|
||||||
import { getAISettings } from "@/app/actions/ai-settings";
|
import { getAISettings } from "@/app/actions/ai-settings";
|
||||||
|
import { AIChatLayoutBridge } from "@/components/ai-chat-layout-bridge";
|
||||||
import { AIChat } from "@/components/ai-chat";
|
|
||||||
|
|
||||||
export default async function MainLayout({
|
export default async function MainLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
// Read browser language hint from Accept-Language header
|
|
||||||
const headersList = await headers();
|
const headersList = await headers();
|
||||||
const browserLang = parseAcceptLanguage(headersList.get("accept-language"));
|
const browserLang = parseAcceptLanguage(headersList.get("accept-language"));
|
||||||
|
|
||||||
// Run auth + language detection + translation loading in parallel
|
|
||||||
const [session, initialLanguage] = await Promise.all([
|
const [session, initialLanguage] = await Promise.all([
|
||||||
auth(),
|
auth(),
|
||||||
detectUserLanguage(browserLang),
|
detectUserLanguage(browserLang),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Load initial translations server-side to prevent hydration mismatch
|
|
||||||
const initialTranslations = await loadTranslations(initialLanguage);
|
const initialTranslations = await loadTranslations(initialLanguage);
|
||||||
|
|
||||||
// Load AI settings to conditionally render AI features
|
|
||||||
const aiSettings = session?.user?.id
|
const aiSettings = session?.user?.id
|
||||||
? await getAISettings(session.user.id)
|
? await getAISettings(session.user.id)
|
||||||
: null;
|
: null;
|
||||||
@@ -36,25 +30,17 @@ export default async function MainLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
<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">
|
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
|
||||||
{/* Top Navigation - Style Keep */}
|
<div className="flex h-screen overflow-hidden bg-[#E5E2D9]">
|
||||||
<HeaderWrapper user={session?.user} />
|
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
|
||||||
|
<Sidebar user={session?.user} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{/* Main Layout */}
|
<main className="memento-paper-texture flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth">
|
||||||
<div className="flex flex-1 overflow-hidden relative">
|
{children}
|
||||||
{/* Sidebar Navigation - Style Keep */}
|
</main>
|
||||||
<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 Content Area */}
|
{showAIAssistant && <AIChatLayoutBridge />}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</ProvidersWrapper>
|
</ProvidersWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ export default async function HomePage() {
|
|||||||
|
|
||||||
const notesViewMode =
|
const notesViewMode =
|
||||||
settings?.notesViewMode === 'masonry'
|
settings?.notesViewMode === 'masonry'
|
||||||
? 'masonry' as const
|
? ('masonry' as const)
|
||||||
: settings?.notesViewMode === 'tabs' || settings?.notesViewMode === 'list'
|
: settings?.notesViewMode === 'tabs'
|
||||||
? 'tabs' as const
|
? ('tabs' as const)
|
||||||
: 'masonry' as const
|
: settings?.notesViewMode === 'list'
|
||||||
|
? ('list' as const)
|
||||||
|
: ('masonry' as const)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeClient
|
<HomeClient
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,12 @@ import { updateUserSettings } from '@/app/actions/user-settings'
|
|||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Palette, Type, LayoutGrid, Maximize2 } from 'lucide-react'
|
import { Palette, Type, LayoutGrid, Maximize2 } from 'lucide-react'
|
||||||
|
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
||||||
|
|
||||||
interface AppearanceSettingsClientProps {
|
interface AppearanceSettingsClientProps {
|
||||||
initialFontSize: string
|
initialFontSize: string
|
||||||
initialTheme: string
|
initialTheme: string
|
||||||
initialNotesViewMode: 'masonry' | 'tabs'
|
initialNotesViewMode: 'masonry' | 'tabs' | 'list'
|
||||||
initialCardSizeMode?: 'variable' | 'uniform'
|
initialCardSizeMode?: 'variable' | 'uniform'
|
||||||
initialFontFamily?: string
|
initialFontFamily?: string
|
||||||
}
|
}
|
||||||
@@ -23,27 +24,18 @@ export function AppearanceSettingsClient({
|
|||||||
initialFontFamily = 'inter',
|
initialFontFamily = 'inter',
|
||||||
}: AppearanceSettingsClientProps) {
|
}: AppearanceSettingsClientProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const [theme, setTheme] = useState(initialTheme || 'light')
|
const [theme, setTheme] = useState<ThemeId>(normalizeThemeId(initialTheme || 'light'))
|
||||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
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 [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode)
|
||||||
const [fontFamily, setFontFamily] = useState(initialFontFamily)
|
const [fontFamily, setFontFamily] = useState(initialFontFamily)
|
||||||
|
|
||||||
const handleThemeChange = async (value: string) => {
|
const handleThemeChange = async (value: string) => {
|
||||||
setTheme(value)
|
const next = normalizeThemeId(value)
|
||||||
localStorage.setItem('theme-preference', value)
|
setTheme(next)
|
||||||
const root = document.documentElement
|
localStorage.setItem('theme-preference', next)
|
||||||
root.removeAttribute('data-theme')
|
applyDocumentTheme(next)
|
||||||
root.classList.remove('dark')
|
await updateUserSettings({ theme: next })
|
||||||
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 })
|
|
||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +48,7 @@ export function AppearanceSettingsClient({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleNotesViewChange = async (value: string) => {
|
const handleNotesViewChange = async (value: string) => {
|
||||||
const mode = value === 'tabs' ? 'tabs' : 'masonry'
|
const mode = value === 'tabs' ? 'tabs' : value === 'list' ? 'list' : 'masonry'
|
||||||
setNotesViewMode(mode)
|
setNotesViewMode(mode)
|
||||||
await updateAISettings({ notesViewMode: mode })
|
await updateAISettings({ notesViewMode: mode })
|
||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
@@ -87,12 +79,14 @@ export function AppearanceSettingsClient({
|
|||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
|
optionGroups,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
value: 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
|
onChange: (v: string) => void
|
||||||
}) => (
|
}) => (
|
||||||
<div className="bg-card rounded-lg border border-border p-6 shadow-sm flex flex-col gap-4">
|
<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)}
|
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"
|
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) => (
|
{optionGroups
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
? 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>
|
</select>
|
||||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground">
|
<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>
|
</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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -135,19 +168,7 @@ export function AppearanceSettingsClient({
|
|||||||
title={t('settings.theme')}
|
title={t('settings.theme')}
|
||||||
description={t('appearance.selectTheme')}
|
description={t('appearance.selectTheme')}
|
||||||
value={theme}
|
value={theme}
|
||||||
options={[
|
optionGroups={themeOptionGroups}
|
||||||
{ 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') },
|
|
||||||
]}
|
|
||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -168,7 +189,7 @@ export function AppearanceSettingsClient({
|
|||||||
<SelectCard
|
<SelectCard
|
||||||
icon={Type}
|
icon={Type}
|
||||||
title={t('appearance.fontFamilyLabel') || 'Police'}
|
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}
|
value={fontFamily}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'inter', label: 'Inter' },
|
{ value: 'inter', label: 'Inter' },
|
||||||
@@ -184,6 +205,7 @@ export function AppearanceSettingsClient({
|
|||||||
value={notesViewMode}
|
value={notesViewMode}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'masonry', label: t('appearance.notesViewMasonry') },
|
{ value: 'masonry', label: t('appearance.notesViewMasonry') },
|
||||||
|
{ value: 'list', label: t('appearance.notesViewList') },
|
||||||
{ value: 'tabs', label: t('appearance.notesViewTabs') },
|
{ value: 'tabs', label: t('appearance.notesViewTabs') },
|
||||||
]}
|
]}
|
||||||
onChange={handleNotesViewChange}
|
onChange={handleNotesViewChange}
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export default async function AppearanceSettingsPage() {
|
|||||||
<AppearanceSettingsClient
|
<AppearanceSettingsClient
|
||||||
initialFontSize={aiSettings.fontSize}
|
initialFontSize={aiSettings.fontSize}
|
||||||
initialTheme={userSettings.theme}
|
initialTheme={userSettings.theme}
|
||||||
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
|
initialNotesViewMode={
|
||||||
|
aiSettings.notesViewMode === 'masonry'
|
||||||
|
? 'masonry'
|
||||||
|
: aiSettings.notesViewMode === 'list'
|
||||||
|
? 'list'
|
||||||
|
: 'tabs'
|
||||||
|
}
|
||||||
initialCardSizeMode={userSettings.cardSizeMode}
|
initialCardSizeMode={userSettings.cardSizeMode}
|
||||||
initialFontFamily={aiSettings.fontFamily || 'inter'}
|
initialFontFamily={aiSettings.fontFamily || 'inter'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -59,13 +59,11 @@ function pickUserAISettingsForDb(input: UserAISettingsData): Partial<Record<User
|
|||||||
out[key] = v
|
out[key] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (out.notesViewMode === 'list') {
|
|
||||||
out.notesViewMode = 'tabs'
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
out.notesViewMode != null &&
|
out.notesViewMode != null &&
|
||||||
out.notesViewMode !== 'masonry' &&
|
out.notesViewMode !== 'masonry' &&
|
||||||
out.notesViewMode !== 'tabs'
|
out.notesViewMode !== 'tabs' &&
|
||||||
|
out.notesViewMode !== 'list'
|
||||||
) {
|
) {
|
||||||
delete out.notesViewMode
|
delete out.notesViewMode
|
||||||
}
|
}
|
||||||
@@ -167,9 +165,11 @@ const getCachedAISettings = unstable_cache(
|
|||||||
const viewMode =
|
const viewMode =
|
||||||
raw === 'masonry'
|
raw === 'masonry'
|
||||||
? ('masonry' as const)
|
? ('masonry' as const)
|
||||||
: raw === 'list' || raw === 'tabs'
|
: raw === 'tabs'
|
||||||
? ('tabs' as const)
|
? ('tabs' as const)
|
||||||
: ('masonry' as const)
|
: raw === 'list'
|
||||||
|
? ('list' as const)
|
||||||
|
: ('masonry' as const)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
titleSuggestions: settings.titleSuggestions,
|
titleSuggestions: settings.titleSuggestions,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { normalizeThemeId } from '@/lib/apply-document-theme'
|
||||||
|
|
||||||
const ProfileSchema = z.object({
|
const ProfileSchema = z.object({
|
||||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
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()
|
const session = await auth()
|
||||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||||
|
|
||||||
|
const normalized = normalizeThemeId(theme)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
data: { theme },
|
data: { theme: normalized },
|
||||||
})
|
})
|
||||||
revalidatePath('/')
|
revalidatePath('/')
|
||||||
revalidatePath('/settings/profile')
|
revalidatePath('/settings/profile')
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { revalidatePath, updateTag } from 'next/cache'
|
import { revalidatePath, updateTag } from 'next/cache'
|
||||||
|
import { unstable_cache } from 'next/cache'
|
||||||
|
import { normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
|
||||||
|
|
||||||
export type UserSettingsData = {
|
export type UserSettingsData = {
|
||||||
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
theme?: ThemeId
|
||||||
cardSizeMode?: 'variable' | 'uniform'
|
cardSizeMode?: 'variable' | 'uniform'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +15,6 @@ export type UserSettingsData = {
|
|||||||
* Update user settings (theme, etc.)
|
* Update user settings (theme, etc.)
|
||||||
*/
|
*/
|
||||||
export async function updateUserSettings(settings: UserSettingsData) {
|
export async function updateUserSettings(settings: UserSettingsData) {
|
||||||
|
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@@ -22,11 +23,14 @@ export async function updateUserSettings(settings: UserSettingsData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await prisma.user.update({
|
const data: { theme?: string; cardSizeMode?: 'variable' | 'uniform' } = {}
|
||||||
where: { id: session.user.id },
|
if (settings.theme !== undefined) data.theme = normalizeThemeId(settings.theme)
|
||||||
data: settings
|
if (settings.cardSizeMode !== undefined) data.cardSizeMode = settings.cardSizeMode
|
||||||
})
|
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
revalidatePath('/', 'layout')
|
revalidatePath('/', 'layout')
|
||||||
updateTag('user-settings')
|
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(
|
const getCachedUserSettings = unstable_cache(
|
||||||
async (userId: string) => {
|
async (userId: string) => {
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { theme: true, cardSizeMode: true }
|
select: { theme: true, cardSizeMode: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue',
|
theme: normalizeThemeId(user?.theme || 'light'),
|
||||||
cardSizeMode: (user?.cardSizeMode || 'variable') as 'variable' | 'uniform'
|
cardSizeMode: (user?.cardSizeMode || 'variable') as 'variable' | 'uniform',
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting user settings:', error)
|
console.error('Error getting user settings:', error)
|
||||||
return {
|
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) {
|
if (!id) {
|
||||||
return {
|
return {
|
||||||
theme: 'light' as const,
|
theme: 'light' as const satisfies ThemeId,
|
||||||
cardSizeMode: 'variable' as const
|
cardSizeMode: 'variable' as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { auth } from '@/auth'
|
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) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const searchParams = request.nextUrl.searchParams
|
const searchParams = request.nextUrl.searchParams
|
||||||
const includeArchived = searchParams.get('archived') === 'true'
|
const includeArchived = searchParams.get('archived') === 'true'
|
||||||
const search = searchParams.get('search')
|
const search = searchParams.get('search')
|
||||||
|
const notebookId = searchParams.get('notebookId')
|
||||||
|
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined
|
||||||
|
|
||||||
let where: any = {
|
let where: any = {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -27,6 +29,10 @@ export async function GET(request: NextRequest) {
|
|||||||
where.isArchived = false
|
where.isArchived = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (notebookId) {
|
||||||
|
where.notebookId = notebookId
|
||||||
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ title: { contains: search, mode: 'insensitive' } },
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
@@ -40,7 +46,8 @@ export async function GET(request: NextRequest) {
|
|||||||
{ isPinned: 'desc' },
|
{ isPinned: 'desc' },
|
||||||
{ order: 'asc' },
|
{ order: 'asc' },
|
||||||
{ updatedAt: 'desc' }
|
{ updatedAt: 'desc' }
|
||||||
]
|
],
|
||||||
|
...(limit ? { take: limit } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -12,22 +12,32 @@
|
|||||||
--breakpoint-large-desktop: 1440px;
|
--breakpoint-large-desktop: 1440px;
|
||||||
--breakpoint-ultra-wide: 1920px;
|
--breakpoint-ultra-wide: 1920px;
|
||||||
|
|
||||||
/* Custom colors matching Keep design */
|
/* Memento — Architectural Grid (réf. architectural-grid1) */
|
||||||
--color-primary: #64748b;
|
--color-memento-desk: #E5E2D9;
|
||||||
--color-background-light: #f7f7f8;
|
--color-memento-paper: #F2F0E9;
|
||||||
--color-background-dark: #1a1d23;
|
--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-sans: var(--font-inter);
|
||||||
--font-heading: var(--font-manrope);
|
--font-heading: var(--font-memento-serif), ui-serif, Georgia, "Times New Roman", serif;
|
||||||
--shadow-level-1: 0px 4px 20px rgba(15, 23, 42, 0.05);
|
--shadow-level-1: 0 2px 4px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.06);
|
||||||
--shadow-level-2: 0px 10px 30px rgba(15, 23, 42, 0.08);
|
--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 {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 3px;
|
||||||
height: 8px;
|
height: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@@ -35,16 +45,211 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #cbd5e1;
|
background: rgba(28, 28, 28, 0.05);
|
||||||
border-radius: 4px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #94a3b8;
|
background: rgba(28, 28, 28, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark ::-webkit-scrollbar-thumb {
|
.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 */
|
/* Custom Prose overrides for compact notes */
|
||||||
@@ -105,133 +310,139 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--background: #f8fafc; /* Sub-surface off-white */
|
--memento-desk: #E5E2D9;
|
||||||
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
|
--background: #F2F0E9;
|
||||||
--card: oklch(1 0 0); /* Blanc pur */
|
--foreground: #1C1C1C;
|
||||||
--card-foreground: oklch(0.2 0.02 230);
|
--card: #faf9f5;
|
||||||
--popover: oklch(1 0 0);
|
--card-foreground: #1C1C1C;
|
||||||
--popover-foreground: oklch(0.2 0.02 230);
|
--popover: #faf9f5;
|
||||||
--primary: #0284c7; /* Sky Blue */
|
--popover-foreground: #1C1C1C;
|
||||||
--primary-foreground: #ffffff; /* Blanc */
|
--primary: #1C1C1C;
|
||||||
--secondary: #e2e8f0; /* Gris-bleu très pâle */
|
--primary-foreground: #F2F0E9;
|
||||||
--secondary-foreground: #1e293b;
|
--secondary: #e8e6df;
|
||||||
--muted: #f1f5f9;
|
--secondary-foreground: #1C1C1C;
|
||||||
--muted-foreground: #64748b;
|
--muted: #e3e1d8;
|
||||||
--accent: #f1f5f9;
|
--muted-foreground: rgba(28, 28, 28, 0.58);
|
||||||
--accent-foreground: #0284c7;
|
--accent: #ebe9e2;
|
||||||
--destructive: oklch(0.6 0.18 25); /* Rouge */
|
--accent-foreground: #1C1C1C;
|
||||||
--border: #cbd5e1; /* Gris-bleu visible */
|
--destructive: #c42b1c;
|
||||||
--input: #cbd5e1; /* Bordure visible pour inputs/checkbox */
|
--border: rgba(28, 28, 28, 0.1);
|
||||||
--ring: #0284c7;
|
--input: rgba(28, 28, 28, 0.12);
|
||||||
|
--ring: rgba(28, 28, 28, 0.35);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.97 0.004 230);
|
--sidebar: color-mix(in oklab, #ffffff 65%, #F2F0E9);
|
||||||
--sidebar-foreground: oklch(0.2 0.02 230);
|
--sidebar-foreground: #1C1C1C;
|
||||||
--sidebar-primary: oklch(0.45 0.08 230);
|
--sidebar-primary: #1C1C1C;
|
||||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
--sidebar-primary-foreground: #F2F0E9;
|
||||||
--sidebar-accent: oklch(0.94 0.005 230);
|
--sidebar-accent: rgba(255, 255, 255, 0.45);
|
||||||
--sidebar-accent-foreground: oklch(0.2 0.02 230);
|
--sidebar-accent-foreground: #1C1C1C;
|
||||||
--sidebar-border: oklch(0.9 0.008 230);
|
--sidebar-border: rgba(28, 28, 28, 0.1);
|
||||||
--sidebar-ring: oklch(0.7 0.005 230);
|
--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'] {
|
[data-theme='light'] {
|
||||||
--background: oklch(0.985 0.003 230); /* Blanc grisâtre */
|
--memento-desk: #E5E2D9;
|
||||||
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
|
--background: #F2F0E9;
|
||||||
--card: oklch(1 0 0); /* Blanc pur */
|
--foreground: #1C1C1C;
|
||||||
--card-foreground: oklch(0.2 0.02 230);
|
--card: #faf9f5;
|
||||||
--popover: oklch(1 0 0);
|
--card-foreground: #1C1C1C;
|
||||||
--popover-foreground: oklch(0.2 0.02 230);
|
--popover: #faf9f5;
|
||||||
--primary: oklch(0.45 0.08 230); /* Gris-bleu doux */
|
--popover-foreground: #1C1C1C;
|
||||||
--primary-foreground: oklch(0.99 0 0); /* Blanc */
|
--primary: #1C1C1C;
|
||||||
--secondary: oklch(0.94 0.005 230); /* Gris-bleu très pâle */
|
--primary-foreground: #F2F0E9;
|
||||||
--secondary-foreground: oklch(0.2 0.02 230);
|
--secondary: #e8e6df;
|
||||||
--muted: oklch(0.92 0.005 230);
|
--secondary-foreground: #1C1C1C;
|
||||||
--muted-foreground: oklch(0.6 0.01 230);
|
--muted: #e3e1d8;
|
||||||
--accent: oklch(0.92 0.005 230);
|
--muted-foreground: rgba(28, 28, 28, 0.58);
|
||||||
--accent-foreground: oklch(0.2 0.02 230);
|
--accent: #ebe9e2;
|
||||||
--destructive: oklch(0.6 0.18 25); /* Rouge */
|
--accent-foreground: #1C1C1C;
|
||||||
--border: oklch(0.85 0.008 230); /* Gris-bleu visible */
|
--destructive: #c42b1c;
|
||||||
--input: oklch(0.85 0.008 230); /* Bordure visible */
|
--border: rgba(28, 28, 28, 0.1);
|
||||||
--ring: oklch(0.7 0.005 230);
|
--input: rgba(28, 28, 28, 0.12);
|
||||||
--popover: oklch(1 0 0);
|
--ring: rgba(28, 28, 28, 0.35);
|
||||||
--popover-foreground: oklch(0.2 0.02 230);
|
--sidebar: color-mix(in oklab, #ffffff 65%, #F2F0E9);
|
||||||
--sidebar: oklch(0.97 0.004 230);
|
--sidebar-foreground: #1C1C1C;
|
||||||
--sidebar-foreground: oklch(0.2 0.02 230);
|
--sidebar-primary: #1C1C1C;
|
||||||
--sidebar-primary: oklch(0.45 0.08 230);
|
--sidebar-primary-foreground: #F2F0E9;
|
||||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
--sidebar-accent: rgba(255, 255, 255, 0.45);
|
||||||
--sidebar-accent: oklch(0.94 0.005 230);
|
--sidebar-accent-foreground: #1C1C1C;
|
||||||
--sidebar-accent-foreground: oklch(0.2 0.02 230);
|
--sidebar-border: rgba(28, 28, 28, 0.1);
|
||||||
--sidebar-border: oklch(0.85 0.008 230);
|
--sidebar-ring: rgba(28, 28, 28, 0.35);
|
||||||
--sidebar-ring: oklch(0.7 0.005 230);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='light'].dark {
|
[data-theme='light'].dark {
|
||||||
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
|
--background: oklch(0.16 0.006 75);
|
||||||
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
|
--foreground: oklch(0.96 0.004 85);
|
||||||
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
|
--card: oklch(0.2 0.008 75);
|
||||||
--card-foreground: oklch(0.97 0.003 230);
|
--card-foreground: oklch(0.96 0.004 85);
|
||||||
--popover: oklch(0.18 0.006 230);
|
--popover: oklch(0.2 0.008 75);
|
||||||
--popover-foreground: oklch(0.97 0.003 230);
|
--popover-foreground: oklch(0.96 0.004 85);
|
||||||
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
|
--primary: oklch(0.78 0.03 75);
|
||||||
--primary-foreground: oklch(0.1 0 0); /* Noir */
|
--primary-foreground: oklch(0.18 0.02 60);
|
||||||
--secondary: oklch(0.24 0.006 230);
|
--secondary: oklch(0.26 0.008 75);
|
||||||
--secondary-foreground: oklch(0.97 0.003 230);
|
--secondary-foreground: oklch(0.96 0.004 85);
|
||||||
--muted: oklch(0.22 0.006 230);
|
--muted: oklch(0.24 0.008 75);
|
||||||
--muted-foreground: oklch(0.55 0.01 230);
|
--muted-foreground: oklch(0.62 0.012 75);
|
||||||
--accent: oklch(0.26 0.008 230);
|
--accent: oklch(0.28 0.01 75);
|
||||||
--accent-foreground: oklch(0.97 0.003 230);
|
--accent-foreground: oklch(0.96 0.004 85);
|
||||||
--destructive: oklch(0.65 0.18 25);
|
--destructive: oklch(0.65 0.18 25);
|
||||||
--border: oklch(0.33 0.01 230);
|
--border: oklch(0.33 0.012 75);
|
||||||
--input: oklch(0.33 0.01 230);
|
--input: oklch(0.33 0.012 75);
|
||||||
--ring: oklch(0.6 0.01 230);
|
--ring: oklch(0.58 0.02 75);
|
||||||
--popover: oklch(0.18 0.006 230);
|
--sidebar: oklch(0.14 0.006 75);
|
||||||
--popover-foreground: oklch(0.97 0.003 230);
|
--sidebar-foreground: oklch(0.96 0.004 85);
|
||||||
--sidebar: oklch(0.12 0.005 230);
|
--sidebar-primary: oklch(0.78 0.03 75);
|
||||||
--sidebar-foreground: oklch(0.97 0.003 230);
|
--sidebar-primary-foreground: oklch(0.18 0.02 60);
|
||||||
--sidebar-primary: oklch(0.55 0.08 230);
|
--sidebar-accent: oklch(0.26 0.01 75);
|
||||||
--sidebar-primary-foreground: oklch(0.1 0 0);
|
--sidebar-accent-foreground: oklch(0.96 0.004 85);
|
||||||
--sidebar-accent: oklch(0.24 0.006 230);
|
--sidebar-border: oklch(0.3 0.012 75);
|
||||||
--sidebar-accent-foreground: oklch(0.97 0.003 230);
|
--sidebar-ring: oklch(0.58 0.02 75);
|
||||||
--sidebar-border: oklch(0.28 0.01 230);
|
|
||||||
--sidebar-ring: oklch(0.6 0.01 230);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
|
--background: #202020;
|
||||||
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
|
--foreground: #ffffff;
|
||||||
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
|
--card: #2d2d2d;
|
||||||
--card-foreground: oklch(0.97 0.003 230);
|
--card-foreground: #ffffff;
|
||||||
--popover: oklch(0.18 0.006 230);
|
--popover: #2d2d2d;
|
||||||
--popover-foreground: oklch(0.97 0.003 230);
|
--popover-foreground: #ffffff;
|
||||||
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
|
--primary: #d6d3d1;
|
||||||
--primary-foreground: oklch(0.1 0 0); /* Noir */
|
--primary-foreground: #1c1917;
|
||||||
--secondary: oklch(0.24 0.006 230);
|
--secondary: #2d2d2d;
|
||||||
--secondary-foreground: oklch(0.97 0.003 230);
|
--secondary-foreground: #ffffff;
|
||||||
--muted: oklch(0.22 0.006 230);
|
--muted: #2d2d2d;
|
||||||
--muted-foreground: oklch(0.55 0.01 230);
|
--muted-foreground: #9e9e9e;
|
||||||
--accent: oklch(0.26 0.008 230);
|
--accent: #383838;
|
||||||
--accent-foreground: oklch(0.97 0.003 230);
|
--accent-foreground: #ffffff;
|
||||||
--destructive: oklch(0.65 0.18 25);
|
--destructive: #ff6b6b;
|
||||||
--border: oklch(0.33 0.01 230);
|
--border: #3d3d3d;
|
||||||
--input: oklch(0.33 0.01 230);
|
--input: #3d3d3d;
|
||||||
--ring: oklch(0.6 0.01 230);
|
--ring: #a8a29e;
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.12 0.005 230);
|
--sidebar: rgba(32, 32, 32, 0.75);
|
||||||
--sidebar-foreground: oklch(0.97 0.003 230);
|
--sidebar-foreground: #ffffff;
|
||||||
--sidebar-primary: oklch(0.55 0.08 230);
|
--sidebar-primary: #d6d3d1;
|
||||||
--sidebar-primary-foreground: oklch(0.1 0 0);
|
--sidebar-primary-foreground: #1c1917;
|
||||||
--sidebar-accent: oklch(0.24 0.006 230);
|
--sidebar-accent: #383838;
|
||||||
--sidebar-accent-foreground: oklch(0.97 0.003 230);
|
--sidebar-accent-foreground: #ffffff;
|
||||||
--sidebar-border: oklch(0.28 0.01 230);
|
--sidebar-border: #3d3d3d;
|
||||||
--sidebar-ring: oklch(0.6 0.01 230);
|
--sidebar-ring: #a8a29e;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='midnight'] {
|
[data-theme='midnight'] {
|
||||||
@@ -781,8 +992,12 @@ html.font-system * {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply text-foreground;
|
||||||
/* Body inherits from html, can be adjusted per language */
|
background-color: var(--memento-desk);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark body {
|
||||||
|
background-color: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Latin languages use default (inherits from html) */
|
/* Latin languages use default (inherits from html) */
|
||||||
@@ -1073,7 +1288,7 @@ html.font-system * {
|
|||||||
background: var(--popover);
|
background: var(--popover);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
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;
|
padding: 2px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1081,7 +1296,7 @@ html.font-system * {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
.dark .notion-bubble-menu {
|
.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 {
|
.notion-bubble-btn {
|
||||||
@@ -1095,7 +1310,10 @@ html.font-system * {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.12s ease;
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.notion-bubble-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
.notion-bubble-btn:hover {
|
.notion-bubble-btn:hover {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -1114,7 +1332,7 @@ html.font-system * {
|
|||||||
background: var(--popover);
|
background: var(--popover);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
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;
|
padding: 4px;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
z-index: 110;
|
z-index: 110;
|
||||||
@@ -1134,7 +1352,7 @@ html.font-system * {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
transition: background 0.1s ease;
|
transition: background 0.15s ease;
|
||||||
}
|
}
|
||||||
.notion-ai-subitem:hover {
|
.notion-ai-subitem:hover {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -1171,8 +1389,8 @@ html.font-system * {
|
|||||||
.notion-ai-result-modal {
|
.notion-ai-result-modal {
|
||||||
background: var(--popover);
|
background: var(--popover);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 14px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 16px 48px rgba(0,0,0,0.12);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
width: min(520px, 90vw);
|
width: min(520px, 90vw);
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
@@ -1226,8 +1444,8 @@ html.font-system * {
|
|||||||
.notion-image-modal {
|
.notion-image-modal {
|
||||||
background: var(--popover);
|
background: var(--popover);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 16px 48px rgba(0,0,0,0.15);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 16px 48px rgba(0,0,0,0.08);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
width: 400px;
|
width: 400px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
@@ -1239,15 +1457,16 @@ html.font-system * {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s ease;
|
transition: border-color var(--transition-fast);
|
||||||
}
|
}
|
||||||
.notion-modal-input:focus {
|
.notion-modal-input:focus {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--primary);
|
||||||
}
|
}
|
||||||
.notion-modal-input::placeholder {
|
.notion-modal-input::placeholder {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
@@ -1255,13 +1474,13 @@ html.font-system * {
|
|||||||
}
|
}
|
||||||
.notion-modal-btn {
|
.notion-modal-btn {
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s ease;
|
transition: background var(--transition-fast);
|
||||||
}
|
}
|
||||||
.notion-modal-btn:hover {
|
.notion-modal-btn:hover {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -1284,22 +1503,22 @@ html.font-system * {
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
background: var(--popover);
|
background: var(--popover);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.08);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--border) transparent;
|
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 {
|
@keyframes slash-enter {
|
||||||
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
}
|
}
|
||||||
.dark .notion-slash-menu {
|
.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 */
|
/* Header hint */
|
||||||
@@ -1331,14 +1550,14 @@ html.font-system * {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 3px 9px;
|
padding: 3px 9px;
|
||||||
border-radius: 20px;
|
border-radius: 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.12s ease;
|
transition: all 0.15s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.notion-slash-tab:hover {
|
.notion-slash-tab:hover {
|
||||||
@@ -1415,11 +1634,11 @@ html.font-system * {
|
|||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 7px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--foreground);
|
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 {
|
[dir="rtl"] .notion-slash-item {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -1439,11 +1658,11 @@ html.font-system * {
|
|||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 7px;
|
border-radius: 6px;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: background 0.1s ease;
|
transition: background 0.15s ease;
|
||||||
}
|
}
|
||||||
.notion-slash-item-selected .notion-slash-icon,
|
.notion-slash-item-selected .notion-slash-icon,
|
||||||
.notion-slash-item:hover .notion-slash-icon {
|
.notion-slash-item:hover .notion-slash-icon {
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { DirectionInitializer } from "@/components/direction-initializer";
|
|||||||
import { ErrorReporter } from "@/components/error-reporter";
|
import { ErrorReporter } from "@/components/error-reporter";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import Script from "next/script";
|
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({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -22,6 +24,12 @@ const manrope = Manrope({
|
|||||||
variable: "--font-manrope",
|
variable: "--font-manrope",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const playfair = Playfair_Display({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-memento-serif",
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Memento - Your Digital Notepad",
|
title: "Memento - Your Digital Notepad",
|
||||||
description: "A beautiful note-taking app built with Next.js 16",
|
description: "A beautiful note-taking app built with Next.js 16",
|
||||||
@@ -38,13 +46,18 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: "#3A7CA5",
|
themeColor: "#1C1C1C",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getHtmlClass(theme?: string): string {
|
function serverHtmlThemeState(theme?: string | null): { className?: string; dataTheme?: string } {
|
||||||
if (theme === 'dark') return 'dark';
|
const t = normalizeThemeId(theme || 'light')
|
||||||
if (theme === 'midnight') return 'dark';
|
if (t === 'auto') return {}
|
||||||
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),
|
getUserSettings(userId),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const htmlTheme = serverHtmlThemeState(userSettings.theme)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html suppressHydrationWarning className={getHtmlClass(userSettings.theme)}>
|
<html
|
||||||
|
suppressHydrationWarning
|
||||||
|
className={htmlTheme.className}
|
||||||
|
data-theme={htmlTheme.dataTheme}
|
||||||
|
>
|
||||||
<head />
|
<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
|
<Script
|
||||||
id="sw-cleanup"
|
id="sw-cleanup"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Presentation,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
@@ -51,11 +52,17 @@ interface AgentCardProps {
|
|||||||
|
|
||||||
// --- Config ---
|
// --- 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 }> = {
|
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' },
|
scraper: { icon: Globe, color: ICON_MARK, bgColor: ICON_BOX },
|
||||||
researcher: { icon: Search, color: 'text-violet-600 dark:text-violet-400', bgColor: 'bg-violet-50 dark:bg-violet-950' },
|
researcher: { icon: Search, color: ICON_MARK, bgColor: ICON_BOX },
|
||||||
monitor: { icon: Eye, color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-50 dark:bg-amber-950' },
|
monitor: { icon: Eye, color: ICON_MARK, bgColor: ICON_BOX },
|
||||||
custom: { icon: Settings, color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-50 dark:bg-emerald-950' },
|
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> = {
|
const frequencyKeys: Record<string, string> = {
|
||||||
@@ -177,7 +184,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
|||||||
<div className={`
|
<div className={`
|
||||||
font-display group flex flex-col bg-card rounded-lg border transition-all duration-200
|
font-display group flex flex-col bg-card rounded-lg border transition-all duration-200
|
||||||
${agent.isEnabled
|
${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'
|
: '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">
|
<div className="flex items-center gap-1.5">
|
||||||
<h3 className="font-semibold text-sm text-card-foreground truncate leading-tight">{agent.name}</h3>
|
<h3 className="font-semibold text-sm text-card-foreground truncate leading-tight">{agent.name}</h3>
|
||||||
{mounted && isNew && (
|
{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')}
|
{t('agents.newBadge')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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'}`)}
|
{t(`agents.types.${agent.type || 'custom'}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<p className="text-[10px] text-muted-foreground font-medium mb-0.5">{t('agents.status.lastStatus')}</p>
|
||||||
{lastAction ? (
|
{lastAction ? (
|
||||||
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
|
<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 === 'success' ? 'text-primary' :
|
||||||
lastAction.status === 'failure' ? 'text-red-600 dark:text-red-400' :
|
lastAction.status === 'failure' ? 'text-destructive' :
|
||||||
lastAction.status === 'running' ? 'text-primary' :
|
lastAction.status === 'running' ? 'text-primary' :
|
||||||
'text-muted-foreground'
|
'text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
@@ -302,7 +309,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
|||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={isDeleting}
|
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')}
|
title={t('agents.actions.delete')}
|
||||||
>
|
>
|
||||||
{isDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
{isDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeUrl(i)}
|
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -721,7 +721,7 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
|||||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>{t(at.labelKey)}</span>
|
<span>{t(at.labelKey)}</span>
|
||||||
{at.external && !isSelected && (
|
{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>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,16 +32,16 @@ export function AgentHelp({ onClose }: AgentHelpProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<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 */}
|
{/* 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">
|
<div className="flex items-center gap-2.5">
|
||||||
<LifeBuoy className="w-5 h-5 text-primary" />
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -53,25 +53,25 @@ export function AgentHelp({ onClose }: AgentHelpProps) {
|
|||||||
<details
|
<details
|
||||||
key={section.key}
|
key={section.key}
|
||||||
open={section.defaultOpen}
|
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">▸</span>
|
<span className="text-primary text-xs transition-transform group-open:rotate-90">▸</span>
|
||||||
{t(`agents.help.${section.key}`)}
|
{t(`agents.help.${section.key}`)}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="pb-4 pl-5 prose prose-slate dark:prose-invert prose-sm max-w-none
|
<div className="pb-4 pl-5 prose prose-sm max-w-none dark:prose-invert
|
||||||
prose-headings:font-semibold prose-headings:text-slate-800 dark:prose-headings:text-slate-200
|
prose-headings:font-semibold prose-headings:text-foreground
|
||||||
prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1
|
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-p:leading-relaxed prose-p:text-muted-foreground prose-p:my-1.5
|
||||||
prose-li:text-slate-600 dark:prose-li:text-slate-400 prose-li:my-0.5
|
prose-li:text-muted-foreground prose-li:my-0.5
|
||||||
prose-strong:text-slate-700 dark:prose-strong:text-slate-300
|
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-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-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-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-th:text-left prose-th:font-medium prose-th:text-foreground 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-td:text-muted-foreground 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-blockquote:border-primary/30 prose-blockquote:text-muted-foreground
|
||||||
">
|
">
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>
|
<Markdown remarkPlugins={[remarkGfm]}>
|
||||||
{t(`agents.help.${section.key}Content`)}
|
{t(`agents.help.${section.key}Content`)}
|
||||||
@@ -82,10 +82,10 @@ export function AgentHelp({ onClose }: AgentHelpProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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
|
<button
|
||||||
onClick={onClose}
|
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')}
|
{t('agents.help.close')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -106,17 +106,17 @@ export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
|||||||
key={action.id}
|
key={action.id}
|
||||||
className={`
|
className={`
|
||||||
p-3 rounded-lg border
|
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 === 'success' ? 'bg-muted/40 border-border' : ''}
|
||||||
${action.status === 'failure' ? 'bg-red-50/50 dark:bg-red-950/50 border-red-100 dark:border-red-900' : ''}
|
${action.status === 'failure' ? 'bg-destructive/5 border-destructive/25' : ''}
|
||||||
${action.status === 'running' ? 'bg-blue-50/50 dark:bg-blue-950/50 border-blue-100 dark:border-blue-900' : ''}
|
${action.status === 'running' ? 'bg-primary/5 border-primary/25' : ''}
|
||||||
${action.status === 'pending' ? 'bg-muted border-border' : ''}
|
${action.status === 'pending' ? 'bg-muted border-border' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">
|
||||||
{action.status === 'success' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
|
{action.status === 'success' && <CheckCircle2 className="w-4 h-4 text-primary" />}
|
||||||
{action.status === 'failure' && <XCircle className="w-4 h-4 text-red-500" />}
|
{action.status === 'failure' && <XCircle className="w-4 h-4 text-destructive" />}
|
||||||
{action.status === 'running' && <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />}
|
{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" />}
|
{action.status === 'pending' && <Clock className="w-4 h-4 text-muted-foreground" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Plus,
|
Plus,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Presentation,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
@@ -43,6 +45,8 @@ const templateConfig = [
|
|||||||
], frequency: 'weekly' },
|
], frequency: 'weekly' },
|
||||||
{ id: 'surveillant', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', urls: [], 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: '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
|
] as const
|
||||||
|
|
||||||
const typeIcons: Record<string, typeof Globe> = {
|
const typeIcons: Record<string, typeof Globe> = {
|
||||||
@@ -50,14 +54,11 @@ const typeIcons: Record<string, typeof Globe> = {
|
|||||||
researcher: Search,
|
researcher: Search,
|
||||||
monitor: Eye,
|
monitor: Eye,
|
||||||
custom: Settings,
|
custom: Settings,
|
||||||
|
'slide-generator': Presentation,
|
||||||
|
'excalidraw-generator': Pencil,
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeColors: Record<string, string> = {
|
const templateIconBox = 'bg-primary/10 text-primary dark:bg-primary/15'
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) {
|
export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
@@ -89,7 +90,11 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
|||||||
? ['web_search', 'web_scrape', 'note_search', 'note_create']
|
? ['web_search', 'web_scrape', 'note_search', 'note_create']
|
||||||
: tpl.type === 'monitor'
|
: tpl.type === 'monitor'
|
||||||
? ['note_search', 'note_read', 'note_create']
|
? ['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 }))
|
toast.success(t('agents.toasts.installSuccess', { name: resolvedName }))
|
||||||
onInstalled()
|
onInstalled()
|
||||||
@@ -115,10 +120,10 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tpl.id}
|
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="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" />
|
<Icon className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="font-medium text-sm text-foreground">{t(nameKey)}</h4>
|
<h4 className="font-medium text-sm text-foreground">{t(nameKey)}</h4>
|
||||||
|
|||||||
12
memento-note/components/ai-chat-layout-bridge.tsx
Normal file
12
memento-note/components/ai-chat-layout-bridge.tsx
Normal 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} />
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ const TONES = [
|
|||||||
{ id: 'casual', label: 'Casual', icon: Coffee },
|
{ id: 'casual', label: 'Casual', icon: Coffee },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AIChat() {
|
export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: boolean } = {}) {
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const webSearchAvailable = useWebSearchAvailable()
|
const webSearchAvailable = useWebSearchAvailable()
|
||||||
const { notebooks } = useNotebooks()
|
const { notebooks } = useNotebooks()
|
||||||
@@ -143,6 +143,7 @@ export function AIChat() {
|
|||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
if (isContextualAIVisible) return null
|
if (isContextualAIVisible) return null
|
||||||
|
if (!showFloatingTrigger) return null
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import {
|
|||||||
Globe, BookOpen, FileText, RotateCcw, Check,
|
Globe, BookOpen, FileText, RotateCcw, Check,
|
||||||
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
|
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
|
||||||
GitMerge, PlusCircle, Eye, Code, Languages,
|
GitMerge, PlusCircle, Eye, Code, Languages,
|
||||||
Presentation, PenTool, ExternalLink,
|
Presentation, PenTool, ExternalLink, ImagePlus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { MarkdownContent } from '@/components/markdown-content'
|
import { MarkdownContent } from '@/components/markdown-content'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -94,6 +95,8 @@ interface ContextualAIChatProps {
|
|||||||
notebooks?: Array<{ id: string; name: string }>
|
notebooks?: Array<{ id: string; name: string }>
|
||||||
/** Extra classes forwarded to the aside root element */
|
/** Extra classes forwarded to the aside root element */
|
||||||
className?: string
|
className?: string
|
||||||
|
/** How to embed generated diagram images (markdown vs rich text HTML) */
|
||||||
|
diagramInsertFormat?: 'markdown' | 'html'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
@@ -109,6 +112,7 @@ export function ContextualAIChat({
|
|||||||
lastActionApplied = false,
|
lastActionApplied = false,
|
||||||
notebooks = [],
|
notebooks = [],
|
||||||
className,
|
className,
|
||||||
|
diagramInsertFormat = 'markdown',
|
||||||
}: ContextualAIChatProps) {
|
}: ContextualAIChatProps) {
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const webSearchAvailable = useWebSearchAvailable()
|
const webSearchAvailable = useWebSearchAvailable()
|
||||||
@@ -135,6 +139,7 @@ export function ContextualAIChat({
|
|||||||
const [slideStyle, setSlideStyle] = useState('soft')
|
const [slideStyle, setSlideStyle] = useState('soft')
|
||||||
const [diagramType, setDiagramType] = useState('auto')
|
const [diagramType, setDiagramType] = useState('auto')
|
||||||
const [diagramStyle, setDiagramStyle] = useState('default')
|
const [diagramStyle, setDiagramStyle] = useState('default')
|
||||||
|
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
|
||||||
|
|
||||||
// Resource tab state
|
// Resource tab state
|
||||||
const [resourceUrl, setResourceUrl] = useState('')
|
const [resourceUrl, setResourceUrl] = useState('')
|
||||||
@@ -329,9 +334,8 @@ export function ContextualAIChat({
|
|||||||
})
|
})
|
||||||
} else if (type === 'diagram' && poll.canvasId) {
|
} else if (type === 'diagram' && poll.canvasId) {
|
||||||
toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', {
|
toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', {
|
||||||
id: toastId, duration: 10000,
|
id: toastId, duration: 8000,
|
||||||
description: t('ai.generate.toastSuccessDiagram') || 'Votre diagramme est disponible dans le Lab.',
|
description: t('ai.generate.diagramReadyHint') || '',
|
||||||
action: { label: t('ai.generate.openDiagram') || 'Ouvrir', onClick: () => { window.location.href = `/lab?id=${poll.canvasId}` } },
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
toast.success(type === 'slides'
|
toast.success(type === 'slides'
|
||||||
@@ -365,6 +369,53 @@ export function ContextualAIChat({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const buildDiagramImageSnippet = (imageUrl: string, alt: string) => {
|
||||||
|
if (diagramInsertFormat === 'html') {
|
||||||
|
const safeAlt = alt.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||||
|
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\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 ────────────────────────────────────────────────────
|
// ── Resource tab handlers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleScrapeUrl = async () => {
|
const handleScrapeUrl = async () => {
|
||||||
@@ -467,46 +518,47 @@ export function ContextualAIChat({
|
|||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
|
|
||||||
{/* ── Header ───────────────────────────────────────────────── */}
|
{/* ── Header ─────────────────────────────────────────── */}
|
||||||
<div className="px-4 py-3 border-b border-border/40 flex items-center justify-between shrink-0">
|
<div className="px-5 pt-5 pb-4 border-b border-border/40 shrink-0">
|
||||||
<div className="min-w-0">
|
<div className="flex items-start justify-between">
|
||||||
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
|
<div className="min-w-0">
|
||||||
<Sparkles className="h-4 w-4 text-primary shrink-0" />
|
<h2 className="font-memento-serif text-xl font-medium text-foreground flex items-center gap-2 leading-tight">
|
||||||
{t('ai.aiNoteTitle')}
|
<Sparkles className="h-4 w-4 text-amber-500 shrink-0" />
|
||||||
</h2>
|
{t('ai.aiNoteTitle')}
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
</h2>
|
||||||
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
|
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.15em] mt-1 truncate font-medium">
|
||||||
</p>
|
{noteTitle ? noteTitle : t('ai.currentNote')}
|
||||||
</div>
|
</p>
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
</div>
|
||||||
{lastActionApplied && onUndoLastAction && (
|
<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>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost" size="icon"
|
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"
|
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||||
onClick={onUndoLastAction}
|
onClick={() => setExpanded(e => !e)}
|
||||||
title={t('ai.undoLastAction')}
|
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>
|
</Button>
|
||||||
)}
|
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7 text-muted-foreground hover:text-foreground">
|
||||||
{/* Expand / Shrink */}
|
<X className="h-3.5 w-3.5" />
|
||||||
<Button
|
</Button>
|
||||||
variant="ghost" size="icon"
|
</div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tabs ─────────────────────────────────────────────────── */}
|
{/* ── Tabs ────────────────────────────────────────────── */}
|
||||||
<div className="flex border-b border-border/40 shrink-0">
|
<div className="flex border-b border-border/40 shrink-0">
|
||||||
{([
|
{([
|
||||||
{ id: 'chat', icon: Bot, label: t('ai.chatTab') },
|
{ id: 'chat', icon: Bot, label: t('ai.chatTab') },
|
||||||
@@ -517,14 +569,17 @@ export function ContextualAIChat({
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id as any)}
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
className={cn(
|
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-3 text-[10px] font-bold uppercase tracking-[0.15em] flex items-center justify-center gap-1.5 transition-all',
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-primary text-primary'
|
? 'text-foreground'
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<tab.icon className="h-3 w-3" />
|
<tab.icon className="h-3 w-3" />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
{activeTab === tab.id && (
|
||||||
|
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1089,11 +1144,24 @@ export function ContextualAIChat({
|
|||||||
|
|
||||||
{/* Diagram result */}
|
{/* Diagram result */}
|
||||||
{generateResult?.type === 'diagram' && generateResult.canvasId && (
|
{generateResult?.type === 'diagram' && generateResult.canvasId && (
|
||||||
<a href={`/lab?id=${generateResult.canvasId}`}
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
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">
|
<a href={`/lab?id=${generateResult.canvasId}`}
|
||||||
<ExternalLink className="h-4 w-4 shrink-0" />
|
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">
|
||||||
{t('ai.generate.openDiagram') || 'Ouvrir dans le Lab'}
|
<PenTool className="h-4 w-4 shrink-0" />
|
||||||
</a>
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section data-testid="favorites-section" className="mb-8">
|
<section data-testid="favorites-section" id="memento-pinned" className="mb-8 scroll-mt-28">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -55,7 +55,7 @@ export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-2xl">📌</span>
|
<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')}
|
{t('notes.pinnedNotes')}
|
||||||
<span className="text-sm font-medium text-muted-foreground ml-2">
|
<span className="text-sm font-medium text-muted-foreground ml-2">
|
||||||
({pinnedNotes.length})
|
({pinnedNotes.length})
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useLabels } from '@/context/LabelContext'
|
|||||||
import { LabelFilter } from './label-filter'
|
import { LabelFilter } from './label-filter'
|
||||||
import { NotificationPanel } from './notification-panel'
|
import { NotificationPanel } from './notification-panel'
|
||||||
import { updateTheme } from '@/app/actions/profile'
|
import { updateTheme } from '@/app/actions/profile'
|
||||||
|
import { applyDocumentTheme, normalizeThemeId } from '@/lib/apply-document-theme'
|
||||||
import { useDebounce } from '@/hooks/use-debounce'
|
import { useDebounce } from '@/hooks/use-debounce'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
@@ -46,7 +47,7 @@ export function Header({
|
|||||||
user
|
user
|
||||||
}: HeaderProps = {}) {
|
}: HeaderProps = {}) {
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
const [theme, setTheme] = useState<string>('light')
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||||
const [isSemanticSearching, setIsSemanticSearching] = useState(false)
|
const [isSemanticSearching, setIsSemanticSearching] = useState(false)
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -167,24 +168,13 @@ export function Header({
|
|||||||
}, [currentUser])
|
}, [currentUser])
|
||||||
|
|
||||||
const applyTheme = async (newTheme: string, persist = true) => {
|
const applyTheme = async (newTheme: string, persist = true) => {
|
||||||
setTheme(newTheme as any)
|
const normalized = normalizeThemeId(newTheme)
|
||||||
localStorage.setItem('theme-preference', newTheme)
|
setTheme(normalized)
|
||||||
|
localStorage.setItem('theme-preference', normalized)
|
||||||
// Remove all theme classes first
|
applyDocumentTheme(normalized)
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (persist && currentUser) {
|
if (persist && currentUser) {
|
||||||
await updateTheme(newTheme)
|
await updateTheme(normalized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,10 +255,10 @@ export function Header({
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
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
|
active
|
||||||
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
|
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
|
||||||
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
|
: "hover:bg-gray-100 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300"
|
||||||
)}
|
)}
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
@@ -283,10 +273,10 @@ export function Header({
|
|||||||
href={href}
|
href={href}
|
||||||
onClick={() => setIsSidebarOpen(false)}
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
className={cn(
|
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
|
active
|
||||||
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
|
? "bg-primary/10 text-primary dark:bg-primary/15 dark:text-primary"
|
||||||
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
|
: "hover:bg-gray-100 dark:hover:bg-white/5 text-gray-700 dark:text-gray-300"
|
||||||
)}
|
)}
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
@@ -301,26 +291,27 @@ export function Header({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Top Navigation - Style Keep */}
|
{/* 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">
|
<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-8">
|
<div className="flex items-center gap-6 md:gap-10">
|
||||||
|
|
||||||
|
|
||||||
{/* Logo MEMENTO */}
|
{/* Wordmark */}
|
||||||
<div className="flex items-center gap-3 text-slate-900 dark:text-white cursor-pointer group" onClick={() => router.push('/')}>
|
<div className="flex cursor-pointer items-center gap-3 text-foreground 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">
|
<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-5 h-5" />
|
<StickyNote className="w-[1.15rem] h-[1.15rem]" strokeWidth={2} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<label className="hidden md:flex flex-col min-w-40 w-96 !h-10">
|
<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="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-slate-400 dark:text-slate-400 flex items-center justify-center pl-4 focus-within:text-primary transition-colors">
|
<div className="text-muted-foreground flex items-center justify-center pl-4 focus-within:text-primary transition-colors">
|
||||||
<Search className="w-4 h-4" />
|
<Search className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<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') }
|
placeholder={t('search.placeholder') }
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -333,15 +324,15 @@ export function Header({
|
|||||||
<div className="flex flex-1 justify-end gap-2 items-center">
|
<div className="flex flex-1 justify-end gap-2 items-center">
|
||||||
|
|
||||||
{/* Quick nav: Notes (hidden-sidebar only), Chat, Agents, Lab */}
|
{/* 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 && (
|
{noSidebarMode && (
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className={cn(
|
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 === '/'
|
pathname === '/'
|
||||||
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
|
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<StickyNote className="h-3.5 w-3.5" />
|
<StickyNote className="h-3.5 w-3.5" />
|
||||||
@@ -351,10 +342,10 @@ export function Header({
|
|||||||
<Link
|
<Link
|
||||||
href="/agents"
|
href="/agents"
|
||||||
className={cn(
|
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'
|
pathname === '/agents'
|
||||||
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
|
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Bot className="h-3.5 w-3.5" />
|
<Bot className="h-3.5 w-3.5" />
|
||||||
@@ -363,10 +354,10 @@ export function Header({
|
|||||||
<Link
|
<Link
|
||||||
href="/lab"
|
href="/lab"
|
||||||
className={cn(
|
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 === '/lab'
|
pathname === '/lab'
|
||||||
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
|
? "bg-card text-primary shadow-sm ring-1 ring-primary/15"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FlaskConical className="h-3.5 w-3.5" />
|
<FlaskConical className="h-3.5 w-3.5" />
|
||||||
@@ -380,7 +371,7 @@ export function Header({
|
|||||||
{/* Settings Button */}
|
{/* Settings Button */}
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
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" />
|
<Settings className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -388,7 +379,7 @@ export function Header({
|
|||||||
{/* User Avatar Menu */}
|
{/* User Avatar Menu */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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}>
|
style={currentUser?.image ? { backgroundImage: `url(${currentUser?.image})` } : undefined}>
|
||||||
{!currentUser?.image && (
|
{!currentUser?.image && (
|
||||||
<span className="text-sm font-semibold">
|
<span className="text-sm font-semibold">
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
'use client'
|
'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 { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { Note } from '@/lib/types'
|
import { Note } from '@/lib/types'
|
||||||
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById } from '@/app/actions/notes'
|
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote } from '@/app/actions/notes'
|
||||||
import { NoteInput } from '@/components/note-input'
|
|
||||||
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
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 { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||||
import { FavoritesSection } from '@/components/favorites-section'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Wand2, ChevronRight, Plus, FileText } from 'lucide-react'
|
import { Plus, ArrowUpDown } from 'lucide-react'
|
||||||
import { useLabels } from '@/context/LabelContext'
|
import { useLabels } from '@/context/LabelContext'
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { LabelFilter } from '@/components/label-filter'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { useHomeView } from '@/context/home-view-context'
|
import { useHomeView } from '@/context/home-view-context'
|
||||||
import { NoteHistoryModal } from '@/components/note-history-modal'
|
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(
|
const NoteEditor = dynamic(
|
||||||
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
@@ -41,7 +41,7 @@ const AutoLabelSuggestionDialog = dynamic(
|
|||||||
|
|
||||||
type InitialSettings = {
|
type InitialSettings = {
|
||||||
showRecentNotes: boolean
|
showRecentNotes: boolean
|
||||||
notesViewMode: 'masonry' | 'tabs'
|
notesViewMode: 'masonry' | 'tabs' | 'list'
|
||||||
noteHistory: boolean
|
noteHistory: boolean
|
||||||
noteHistoryMode: 'manual' | 'auto'
|
noteHistoryMode: 'manual' | 'auto'
|
||||||
}
|
}
|
||||||
@@ -63,11 +63,14 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
|
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
|
||||||
const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode)
|
const [noteHistoryMode] = useState<'manual' | 'auto'>(initialSettings.noteHistoryMode)
|
||||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
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 [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
|
||||||
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
|
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
|
||||||
const [historyOpen, setHistoryOpen] = useState(false)
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
const [historyNote, setHistoryNote] = useState<Note | null>(null)
|
const [historyNote, setHistoryNote] = useState<Note | null>(null)
|
||||||
|
const [isCreating, startCreating] = useTransition()
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
||||||
|
const [showSortMenu, setShowSortMenu] = useState(false)
|
||||||
const { refreshKey, triggerRefresh } = useNoteRefresh()
|
const { refreshKey, triggerRefresh } = useNoteRefresh()
|
||||||
const { labels } = useLabels()
|
const { labels } = useLabels()
|
||||||
const { setControls } = useHomeView()
|
const { setControls } = useHomeView()
|
||||||
@@ -81,9 +84,20 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
}
|
}
|
||||||
}, [shouldSuggestLabels, suggestNotebookId])
|
}, [shouldSuggestLabels, suggestNotebookId])
|
||||||
|
|
||||||
const notebookFilter = searchParams.get('notebook')
|
// BUG FIX: forceList param from sidebar carnet click → reset to editorial view
|
||||||
const isInbox = !notebookFilter
|
useEffect(() => {
|
||||||
|
const forceList = searchParams.get('forceList')
|
||||||
|
if (forceList === '1') {
|
||||||
|
setNotesViewMode(prev => (prev === 'tabs' ? 'masonry' : prev))
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.delete('forceList')
|
||||||
|
const newUrl = params.toString() ? `/?${params.toString()}` : '/'
|
||||||
|
router.replace(newUrl, { scroll: false })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const notebookFilter = searchParams.get('notebook')
|
||||||
const handleNoteCreated = useCallback((note: Note) => {
|
const handleNoteCreated = useCallback((note: Note) => {
|
||||||
setNotes((prevNotes) => {
|
setNotes((prevNotes) => {
|
||||||
const notebookFilter = searchParams.get('notebook')
|
const notebookFilter = searchParams.get('notebook')
|
||||||
@@ -123,10 +137,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) {
|
if (!note.notebookId) {
|
||||||
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
|
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
|
||||||
if (wordCount >= 20) {
|
if (wordCount >= 20) {
|
||||||
@@ -140,6 +150,25 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
if (note) setEditingNote({ note, readOnly: false })
|
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) => {
|
const handleOpenHistory = useCallback((note: Note) => {
|
||||||
setHistoryNote(note)
|
setHistoryNote(note)
|
||||||
setHistoryOpen(true)
|
setHistoryOpen(true)
|
||||||
@@ -147,7 +176,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
|
|
||||||
const handleEnableHistory = useCallback(async (noteId: string) => {
|
const handleEnableHistory = useCallback(async (noteId: string) => {
|
||||||
await enableNoteHistory(noteId)
|
await enableNoteHistory(noteId)
|
||||||
// Update the specific note in state
|
|
||||||
setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n)))
|
setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, historyEnabled: true } : n)))
|
||||||
setPinnedNotes((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))
|
setEditingNote((prev) => (prev?.note.id === noteId ? { ...prev, note: { ...prev.note, historyEnabled: true } } : prev))
|
||||||
@@ -168,24 +196,20 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
|
|
||||||
useReminderCheck(notes)
|
useReminderCheck(notes)
|
||||||
|
|
||||||
// Handle ?openNote=ID — open a note from a notification click
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const openNoteId = searchParams.get('openNote')
|
const openNoteId = searchParams.get('openNote')
|
||||||
if (!openNoteId) return
|
if (!openNoteId) return
|
||||||
|
|
||||||
const openNote = async () => {
|
const openNote = async () => {
|
||||||
// Try to find the note in current state first
|
|
||||||
const existing = notes.find(n => n.id === openNoteId)
|
const existing = notes.find(n => n.id === openNoteId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setEditingNote({ note: existing, readOnly: false })
|
setEditingNote({ note: existing, readOnly: false })
|
||||||
} else {
|
} else {
|
||||||
// Fetch from server
|
|
||||||
const fetched = await getNoteById(openNoteId)
|
const fetched = await getNoteById(openNoteId)
|
||||||
if (fetched) {
|
if (fetched) {
|
||||||
setEditingNote({ note: fetched, readOnly: false })
|
setEditingNote({ note: fetched, readOnly: false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clean URL — remove openNote param
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
params.delete('openNote')
|
params.delete('openNote')
|
||||||
router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false })
|
router.replace(params.toString() ? `/?${params.toString()}` : '/', { scroll: false })
|
||||||
@@ -195,7 +219,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [searchParams])
|
}, [searchParams])
|
||||||
|
|
||||||
// Listen for global label deletion and immediately update local state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
const { name } = (e as CustomEvent).detail
|
const { name } = (e as CustomEvent).detail
|
||||||
@@ -215,8 +238,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
|
|
||||||
const prevRefreshKey = useRef(refreshKey)
|
const prevRefreshKey = useRef(refreshKey)
|
||||||
|
|
||||||
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
|
|
||||||
// Les notes initiales suffisent sans filtre
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const search = searchParams.get('search')?.trim() || null
|
const search = searchParams.get('search')?.trim() || null
|
||||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||||
@@ -227,8 +248,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
const isBackgroundRefresh = refreshKey > prevRefreshKey.current
|
const isBackgroundRefresh = refreshKey > prevRefreshKey.current
|
||||||
prevRefreshKey.current = refreshKey
|
prevRefreshKey.current = refreshKey
|
||||||
|
|
||||||
// Pour le refreshKey (mutations), toujours recharger
|
|
||||||
// Pour les filtres, charger depuis le serveur
|
|
||||||
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
|
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -243,14 +262,12 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtre labels
|
|
||||||
if (labelFilter.length > 0) {
|
if (labelFilter.length > 0) {
|
||||||
allNotes = allNotes.filter((note: any) =>
|
allNotes = allNotes.filter((note: any) =>
|
||||||
note.labels?.some((label: string) => labelFilter.includes(label))
|
note.labels?.some((label: string) => labelFilter.includes(label))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtre couleur
|
|
||||||
if (colorFilter) {
|
if (colorFilter) {
|
||||||
const labelNamesWithColor = labels
|
const labelNamesWithColor = labels
|
||||||
.filter((label: any) => label.color === colorFilter)
|
.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 => {
|
setNotes(prev => {
|
||||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||||
return allNotes.map(n => ({ ...n, size: localSizeMap.get(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)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Éviter le rechargement initial si les notes sont déjà chargées sans filtres
|
|
||||||
if (refreshKey > 0 || hasActiveFilter) {
|
if (refreshKey > 0 || hasActiveFilter) {
|
||||||
const cancelled = { value: false }
|
const cancelled = { value: false }
|
||||||
load().then(() => { if (cancelled.value) return })
|
load().then(() => { if (cancelled.value) return })
|
||||||
return () => { cancelled.value = true }
|
return () => { cancelled.value = true }
|
||||||
} else {
|
} else {
|
||||||
// Données initiales : filtrage inbox/notebook côté client seulement
|
|
||||||
let filtered = initialNotes
|
let filtered = initialNotes
|
||||||
if (notebook) {
|
if (notebook) {
|
||||||
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
|
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
|
||||||
} else {
|
} else {
|
||||||
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
|
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
|
||||||
}
|
}
|
||||||
// Merger avec les tailles déjà modifiées localement
|
|
||||||
setNotes(prev => {
|
setNotes(prev => {
|
||||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||||
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||||
@@ -298,148 +311,120 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setControls({
|
setControls({
|
||||||
isTabsMode: notesViewMode === 'tabs',
|
isTabsMode: notesViewMode === 'tabs',
|
||||||
openNoteComposer: () => {},
|
openNoteComposer: () => handleAddNote(),
|
||||||
})
|
})
|
||||||
return () => setControls(null)
|
return () => setControls(null)
|
||||||
}, [notesViewMode, setControls])
|
}, [notesViewMode, setControls])
|
||||||
|
|
||||||
const handleNoteCreatedWrapper = (note: any) => {
|
// Apply sort order to notes
|
||||||
handleNoteCreated(note)
|
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 isTabs = notesViewMode === 'tabs'
|
||||||
|
const isEditorialMode = !isTabs
|
||||||
|
|
||||||
|
const handleEditorClose = useCallback(() => {
|
||||||
|
setEditingNote(null)
|
||||||
|
triggerRefresh()
|
||||||
|
}, [triggerRefresh])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full min-h-0 flex-1 flex-col',
|
'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 */}
|
{editingNote ? (
|
||||||
{currentNotebook ? (
|
<NoteEditor
|
||||||
<div
|
note={editingNote.note}
|
||||||
className={cn(
|
readOnly={editingNote.readOnly}
|
||||||
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
onClose={handleEditorClose}
|
||||||
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
fullPage
|
||||||
)}
|
/>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
<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')}
|
|
||||||
>
|
|
||||||
<Wand2 className="h-4 w-4 text-purple-600" />
|
|
||||||
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</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 && (
|
<div className={cn(
|
||||||
<FavoritesSection
|
'px-12 pt-12 pb-8 flex flex-col gap-6',
|
||||||
pinnedNotes={pinnedNotes}
|
isEditorialMode ? 'sticky top-0 bg-background/80 backdrop-blur-md z-30' : ''
|
||||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
)}>
|
||||||
onSizeChange={handleSizeChange}
|
<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>
|
||||||
|
|
||||||
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
|
<div className="flex items-center justify-between border-b border-foreground/5 pb-4">
|
||||||
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
|
<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'}
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} />
|
||||||
|
<span className="hidden sm:inline text-[11px] uppercase tracking-wider font-bold">{sortLabels[sortOrder]}</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 className="px-12 flex-1 pb-20">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
|
||||||
|
) : isTabs ? (
|
||||||
<NotesMainSection
|
<NotesMainSection
|
||||||
viewMode={notesViewMode}
|
viewMode={notesViewMode}
|
||||||
notes={isTabs ? notes : notes.filter((note) => !note.isPinned)}
|
notes={sortedNotes}
|
||||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||||
onSizeChange={handleSizeChange}
|
onSizeChange={handleSizeChange}
|
||||||
currentNotebookId={searchParams.get('notebook')}
|
currentNotebookId={searchParams.get('notebook')}
|
||||||
@@ -448,14 +433,54 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
onEnableHistory={handleEnableHistory}
|
onEnableHistory={handleEnableHistory}
|
||||||
onNoteCreated={handleNoteCreated}
|
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 && (
|
{sortedNotes.filter((note) => !note.isPinned).length > 0 && (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<NotesEditorialView
|
||||||
{t('notes.emptyState')}
|
notes={sortedNotes.filter((note) => !note.isPinned)}
|
||||||
</div>
|
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 — {new Date().getFullYear()}
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -489,14 +514,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingNote && (
|
|
||||||
<NoteEditor
|
|
||||||
note={editingNote.note}
|
|
||||||
readOnly={editingNote.readOnly}
|
|
||||||
onClose={() => setEditingNote(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NoteHistoryModal
|
<NoteHistoryModal
|
||||||
open={historyOpen}
|
open={historyOpen}
|
||||||
onOpenChange={setHistoryOpen}
|
onOpenChange={setHistoryOpen}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function LabelBadge({
|
|||||||
variant === 'filter' && 'cursor-pointer hover:opacity-80',
|
variant === 'filter' && 'cursor-pointer hover:opacity-80',
|
||||||
variant === 'clickable' && 'cursor-pointer',
|
variant === 'clickable' && 'cursor-pointer',
|
||||||
isDisabled && 'opacity-50',
|
isDisabled && 'opacity-50',
|
||||||
isSelected && 'ring-2 ring-blue-500'
|
isSelected && 'ring-2 ring-primary'
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
|
|||||||
224
memento-note/components/markdown-slash-commands.tsx
Normal file
224
memento-note/components/markdown-slash-commands.tsx
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ function getAvatarColor(name: string): string {
|
|||||||
'bg-amber-600',
|
'bg-amber-600',
|
||||||
'bg-pink-600',
|
'bg-pink-600',
|
||||||
'bg-teal-600',
|
'bg-teal-600',
|
||||||
'bg-blue-600',
|
'bg-stone-600',
|
||||||
'bg-indigo-600',
|
'bg-indigo-600',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -456,9 +456,9 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
}}
|
}}
|
||||||
onDragEnd={() => onDragEnd?.()}
|
onDragEnd={() => onDragEnd?.()}
|
||||||
className={cn(
|
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',
|
'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.bg,
|
||||||
colorClasses.card,
|
colorClasses.card,
|
||||||
colorClasses.hover,
|
colorClasses.hover,
|
||||||
|
|||||||
243
memento-note/components/note-document-info-panel.tsx
Normal file
243
memento-note/components/note-document-info-panel.tsx
Normal 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-72 shrink-0 flex-col border-l border-border/60 bg-card overflow-hidden">
|
||||||
|
|
||||||
|
{/* Header tabs */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border/60">
|
||||||
|
<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-lg text-xs font-medium transition-colors',
|
||||||
|
activeTab === tab
|
||||||
|
? 'bg-foreground text-background'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
|
||||||
|
{/* ── INFO TAB ── */}
|
||||||
|
{activeTab === 'info' && (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 border-b border-border/40">
|
||||||
|
<div className="flex flex-col items-center gap-0.5 py-4 border-r border-border/40">
|
||||||
|
<span className="text-2xl font-bold font-memento-serif tabular-nums">{words}</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-widest text-muted-foreground">mots</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-0.5 py-4">
|
||||||
|
<span className="text-2xl font-bold font-memento-serif tabular-nums">{chars}</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-widest text-muted-foreground">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)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,8 +25,12 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { NoteTypeSelector } from '@/components/note-type-selector'
|
import { NoteTypeSelector } from '@/components/note-type-selector'
|
||||||
import { RichTextEditor } from '@/components/rich-text-editor'
|
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 { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut, ArrowLeft, Info, Check, Loader2 } from 'lucide-react'
|
||||||
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
|
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } 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 { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||||
import { cn, extractImagesFromHTML } from '@/lib/utils'
|
import { cn, extractImagesFromHTML } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -57,9 +61,10 @@ interface NoteEditorProps {
|
|||||||
note: Note
|
note: Note
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
onClose: () => void
|
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 { data: session } = useSession()
|
||||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||||
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||||
@@ -147,6 +152,17 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
// AI processing state for ActionBar
|
// AI processing state for ActionBar
|
||||||
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
||||||
const [aiOpen, setAiOpen] = 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
|
// Track previous content for copilot action undo
|
||||||
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
|
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -642,6 +658,179 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── fullPage mode: early return with editorial layout ──
|
||||||
|
if (fullPage) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* main scrollable column */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||||
|
|
||||||
|
{/* sticky toolbar */}
|
||||||
|
<div className="px-6 py-3 flex items-center justify-between sticky top-0 bg-background/90 backdrop-blur-sm z-40 border-b border-border gap-4">
|
||||||
|
<button onClick={onClose} className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity shrink-0">
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
<span className="text-sm font-medium hidden sm:inline">{t('notes.backToCollection') || 'Retour'}</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 flex justify-center">
|
||||||
|
<NoteTypeSelector
|
||||||
|
value={noteType}
|
||||||
|
onChange={(newType) => { setNoteType(newType); setShowMarkdownPreview(newType === 'markdown'); setIsDirty(true) }}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<span className="hidden sm:flex items-center gap-1 text-[11px] text-muted-foreground/50 mr-2 select-none">
|
||||||
|
{isSaving
|
||||||
|
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving') || 'Saving...'}</span></>
|
||||||
|
: isDirty
|
||||||
|
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>{t('notes.dirtyStatus') || 'Modifié'}</span></>
|
||||||
|
: <><Check className="h-3 w-3 text-emerald-500" /><span>{t('notes.savedStatus') || 'Enregistré'}</span></>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { setAiOpen(v => !v); setInfoOpen(false) }}
|
||||||
|
className={cn('flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all',
|
||||||
|
aiOpen ? 'bg-foreground text-background border-foreground' : 'border-border text-muted-foreground hover:text-foreground hover:bg-muted')}
|
||||||
|
>
|
||||||
|
<Sparkles size={13} /><span className="hidden sm:inline">IA</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setInfoOpen(v => !v); setAiOpen(false) }}
|
||||||
|
className={cn('flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all',
|
||||||
|
infoOpen ? 'bg-foreground text-background border-foreground' : 'border-border text-muted-foreground hover:text-foreground hover:bg-muted')}
|
||||||
|
>
|
||||||
|
<Info size={13} /><span className="hidden sm:inline">Info</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* body */}
|
||||||
|
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12">
|
||||||
|
{/* meta */}
|
||||||
|
<div className="text-[12px] text-muted-foreground uppercase tracking-[.25em] font-bold" suppressHydrationWarning>
|
||||||
|
{format(new Date(note.contentUpdatedAt), 'MMM d, yyyy')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* title + AI */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="group relative">
|
||||||
|
<input
|
||||||
|
dir="auto" type="text"
|
||||||
|
placeholder={t('notes.titlePlaceholder')}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => { setTitle(e.target.value); setIsDirty(true); setDismissedTitleSuggestions(true) }}
|
||||||
|
disabled={readOnly}
|
||||||
|
className="w-full text-5xl md:text-6xl font-memento-serif font-bold border-0 outline-none px-0 bg-transparent text-foreground leading-tight placeholder:text-muted-foreground/30 pr-14"
|
||||||
|
/>
|
||||||
|
{!title && !readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const plain = content.replace(/<[^>]+>/g, ' ').trim()
|
||||||
|
if (plain.split(/\s+/).filter(Boolean).length < 3) 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.title || data.suggestedTitle || (data.suggestions?.[0]?.title ?? '')
|
||||||
|
if (s) { setTitle(s); setIsDirty(true) }
|
||||||
|
}
|
||||||
|
} catch {} finally { setIsProcessingAI(false) }
|
||||||
|
}}
|
||||||
|
disabled={isProcessingAI}
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 opacity-30 hover:opacity-100 transition-opacity rounded-lg p-2 text-muted-foreground hover:bg-muted hover:text-primary"
|
||||||
|
title={t('ai.suggestTitle') || 'Générer un titre IA'}
|
||||||
|
>
|
||||||
|
{isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!title && !dismissedTitleSuggestions && autoTitleSuggestions.length > 0 && (
|
||||||
|
<TitleSuggestions
|
||||||
|
suggestions={autoTitleSuggestions}
|
||||||
|
onSelect={(s) => { setTitle(s); setDismissedTitleSuggestions(true); setIsDirty(true) }}
|
||||||
|
onDismiss={() => setDismissedTitleSuggestions(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* editor */}
|
||||||
|
<div className="max-w-2xl mx-auto pb-32">
|
||||||
|
{noteType === 'richtext' ? (
|
||||||
|
<RichTextEditor
|
||||||
|
content={content}
|
||||||
|
onChange={(v) => { setContent(v); setIsDirty(true) }}
|
||||||
|
className="min-h-[200px] text-lg font-light leading-relaxed"
|
||||||
|
onImageUpload={uploadImageFile}
|
||||||
|
/>
|
||||||
|
) : noteType === 'markdown' && showMarkdownPreview ? (
|
||||||
|
<div
|
||||||
|
className="min-h-[200px] cursor-text prose prose-sm dark:prose-invert max-w-none text-base leading-relaxed"
|
||||||
|
onClick={() => setShowMarkdownPreview(false)}
|
||||||
|
>
|
||||||
|
<MarkdownContent content={content} />
|
||||||
|
<p className="text-[11px] text-muted-foreground/40 mt-4 select-none">Cliquez pour éditer</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
dir="auto"
|
||||||
|
placeholder={t('notes.takeNote') || "Tapez '/' pour les commandes..."}
|
||||||
|
value={content}
|
||||||
|
onFocus={() => setShowMarkdownPreview(false)}
|
||||||
|
onChange={(e) => { setContent(e.target.value); setIsDirty(true) }}
|
||||||
|
disabled={readOnly}
|
||||||
|
className="w-full min-h-[400px] border-0 outline-none px-0 bg-transparent text-lg leading-relaxed font-light resize-none placeholder:text-muted-foreground/30"
|
||||||
|
/>
|
||||||
|
{noteType === 'markdown' && content && !readOnly && (
|
||||||
|
<button type="button" onClick={() => setShowMarkdownPreview(true)}
|
||||||
|
className="mt-2 text-[11px] text-muted-foreground/50 hover:text-foreground flex items-center gap-1 transition-colors">
|
||||||
|
<Eye className="h-3 w-3" /> Prévisualiser le Markdown
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!readOnly && (
|
||||||
|
<MarkdownSlashCommands
|
||||||
|
textareaRef={textareaRef as React.RefObject<HTMLTextAreaElement>}
|
||||||
|
value={content}
|
||||||
|
onChange={(v) => { setContent(v); setIsDirty(true) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* side panels */}
|
||||||
|
{aiOpen && (
|
||||||
|
<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'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{infoOpen && (
|
||||||
|
<NoteDocumentInfoPanel
|
||||||
|
note={note} content={content}
|
||||||
|
onClose={() => setInfoOpen(false)}
|
||||||
|
onNoteRestored={(r) => { setContent(r.content || ''); setTitle(r.title || ''); setIsDirty(false) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
|
||||||
|
</div>
|
||||||
|
<ReminderDialog open={showReminderDialog} onOpenChange={setShowReminderDialog} currentReminder={currentReminder} onSave={handleReminderSave} onRemove={handleRemoveReminder} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
|||||||
@@ -932,6 +932,7 @@ export function NoteInlineEditor({
|
|||||||
} : undefined}
|
} : undefined}
|
||||||
lastActionApplied={previousContent !== null}
|
lastActionApplied={previousContent !== null}
|
||||||
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
||||||
|
diagramInsertFormat={noteType === 'richtext' ? 'html' : 'markdown'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -733,7 +733,9 @@ export function NoteInput({
|
|||||||
: 'max-w-2xl mx-auto'
|
: 'max-w-2xl mx-auto'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
|
id="memento-note-composer"
|
||||||
|
className={cn(
|
||||||
'mb-8 flex flex-row items-stretch transition-all duration-300',
|
'mb-8 flex flex-row items-stretch transition-all duration-300',
|
||||||
(aiOpen || isExpandedFull) ? 'max-h-[calc(100vh-180px)]' : '',
|
(aiOpen || isExpandedFull) ? 'max-h-[calc(100vh-180px)]' : '',
|
||||||
widthClass
|
widthClass
|
||||||
@@ -1079,6 +1081,7 @@ export function NoteInput({
|
|||||||
}}
|
}}
|
||||||
lastActionApplied={false}
|
lastActionApplied={false}
|
||||||
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
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"
|
className="border border-border border-l-0 rounded-r-xl overflow-hidden shadow-sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function NotebooksList() {
|
|||||||
<LabelManagementDialog open={labelsDialogOpen} onOpenChange={setLabelsDialogOpen} />
|
<LabelManagementDialog open={labelsDialogOpen} onOpenChange={setLabelsDialogOpen} />
|
||||||
<div className="flex flex-col pt-1">
|
<div className="flex flex-col pt-1">
|
||||||
{/* Header with Add Button */}
|
{/* 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>
|
<span className="text-xs font-semibold uppercase tracking-wider">{t('nav.notebooks') || 'NOTEBOOKS'}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreateDialogOpen(true)}
|
onClick={() => setIsCreateDialogOpen(true)}
|
||||||
@@ -218,7 +218,7 @@ export function NotebooksList() {
|
|||||||
style={notebook.color ? { backgroundColor: `${notebook.color}20` } : undefined}
|
style={notebook.color ? { backgroundColor: `${notebook.color}20` } : undefined}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* 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">
|
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||||
<NotebookIcon
|
<NotebookIcon
|
||||||
className={cn("w-5 h-5 flex-shrink-0 fill-current", !notebook.color && "text-primary dark:text-primary-foreground")}
|
className={cn("w-5 h-5 flex-shrink-0 fill-current", !notebook.color && "text-primary dark:text-primary-foreground")}
|
||||||
@@ -304,7 +304,7 @@ export function NotebooksList() {
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center relative",
|
"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}>
|
<TooltipProvider delayDuration={600}>
|
||||||
|
|||||||
192
memento-note/components/notes-editorial-view.tsx
Normal file
192
memento-note/components/notes-editorial-view.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import type { Note } from '@/lib/types'
|
||||||
|
import { getNoteFeedImage, getNotePlainExcerpt, getNoteDisplayTitle } from '@/lib/note-preview'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil } from 'lucide-react'
|
||||||
|
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 { triggerRefresh } = useNoteRefresh()
|
||||||
|
const [, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await deleteNote(note.id)
|
||||||
|
triggerRefresh()
|
||||||
|
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)
|
||||||
|
triggerRefresh()
|
||||||
|
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)
|
||||||
|
triggerRefresh()
|
||||||
|
} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotesEditorialView({
|
||||||
|
notes,
|
||||||
|
onOpen,
|
||||||
|
notebookName,
|
||||||
|
onOpenHistory,
|
||||||
|
}: NotesEditorialViewProps) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
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 img = getNoteFeedImage(note)
|
||||||
|
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">
|
||||||
|
<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 ? (
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full bg-gradient-to-br from-muted/40 to-muted/10" aria-hidden />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
memento-note/components/notes-list-view.tsx
Normal file
166
memento-note/components/notes-list-view.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { Note } from '@/lib/types'
|
import { Note } from '@/lib/types'
|
||||||
import { NotesTabsView } from '@/components/notes-tabs-view'
|
import { NotesTabsView } from '@/components/notes-tabs-view'
|
||||||
|
import { NotesListView } from '@/components/notes-list-view'
|
||||||
|
|
||||||
const MasonryGridLazy = dynamic(
|
const MasonryGridLazy = dynamic(
|
||||||
() => import('@/components/masonry-grid').then((m) => m.MasonryGrid),
|
() => 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 {
|
interface NotesMainSectionProps {
|
||||||
notes: Note[]
|
notes: Note[]
|
||||||
@@ -42,6 +43,14 @@ export function NotesMainSection({
|
|||||||
onEnableHistory,
|
onEnableHistory,
|
||||||
onNoteCreated,
|
onNoteCreated,
|
||||||
}: NotesMainSectionProps) {
|
}: NotesMainSectionProps) {
|
||||||
|
if (viewMode === 'list') {
|
||||||
|
return (
|
||||||
|
<div data-testid="notes-list">
|
||||||
|
<NotesListView notes={notes} onEdit={onEdit} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (viewMode === 'tabs') {
|
if (viewMode === 'tabs') {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
|
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
|
||||||
|
|||||||
69
memento-note/components/notes-notebook-feed.tsx
Normal file
69
memento-note/components/notes-notebook-feed.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ const COLOR_ACCENT: Record<NoteColor, string> = {
|
|||||||
yellow: 'bg-amber-400',
|
yellow: 'bg-amber-400',
|
||||||
green: 'bg-emerald-400',
|
green: 'bg-emerald-400',
|
||||||
teal: 'bg-teal-400',
|
teal: 'bg-teal-400',
|
||||||
blue: 'bg-sky-400',
|
blue: 'bg-slate-400',
|
||||||
purple: 'bg-violet-400',
|
purple: 'bg-violet-400',
|
||||||
pink: 'bg-fuchsia-400',
|
pink: 'bg-fuchsia-400',
|
||||||
gray: 'bg-gray-400',
|
gray: 'bg-gray-400',
|
||||||
@@ -118,7 +118,7 @@ const COLOR_PANEL_BG: Record<NoteColor, string> = {
|
|||||||
yellow: 'from-amber-50/60 dark:from-amber-950/20 to-background',
|
yellow: 'from-amber-50/60 dark:from-amber-950/20 to-background',
|
||||||
green: 'from-emerald-50/60 dark:from-emerald-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',
|
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',
|
purple: 'from-violet-50/60 dark:from-violet-950/20 to-background',
|
||||||
pink: 'from-fuchsia-50/60 dark:from-fuchsia-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',
|
gray: 'from-gray-50/60 dark:from-gray-900/20 to-background',
|
||||||
@@ -131,7 +131,7 @@ const COLOR_ICON: Record<NoteColor, string> = {
|
|||||||
yellow: 'text-amber-500',
|
yellow: 'text-amber-500',
|
||||||
green: 'text-emerald-500',
|
green: 'text-emerald-500',
|
||||||
teal: 'text-teal-500',
|
teal: 'text-teal-500',
|
||||||
blue: 'text-sky-500',
|
blue: 'text-slate-600',
|
||||||
purple: 'text-violet-500',
|
purple: 'text-violet-500',
|
||||||
pink: 'text-fuchsia-500',
|
pink: 'text-fuchsia-500',
|
||||||
gray: 'text-gray-500',
|
gray: 'text-gray-500',
|
||||||
@@ -368,13 +368,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",
|
"group flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-[13px] font-medium transition-all duration-150",
|
||||||
disabled
|
disabled
|
||||||
? "cursor-not-allowed text-muted-foreground/60 opacity-70"
|
? "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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors",
|
"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}
|
{icon}
|
||||||
@@ -519,9 +519,9 @@ function NoteMetaSidebar({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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
|
{isMoving
|
||||||
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
: <FolderInput className="h-3.5 w-3.5" />}
|
: <FolderInput className="h-3.5 w-3.5" />}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useTransition } from 'react'
|
import { useTransition } from 'react'
|
||||||
import { LayoutGrid, PanelsTopLeft } from 'lucide-react'
|
import { LayoutGrid, PanelsTopLeft, List } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
@@ -63,6 +63,26 @@ export function NotesViewToggle({ mode, onModeChange, className }: NotesViewTogg
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">{t('notes.viewCardsTooltip')}</TooltipContent>
|
<TooltipContent side="bottom">{t('notes.viewCardsTooltip')}</TooltipContent>
|
||||||
</Tooltip>
|
</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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -389,25 +389,25 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5 px-1 py-0.5 relative">
|
<div className="flex items-center gap-0.5 px-1 py-0.5 relative">
|
||||||
{marks.map((m, i) => (
|
{marks.map((m, i) => (
|
||||||
<button key={i} onClick={m.action} title={m.title} className={cn('notion-bubble-btn', m.active && 'notion-bubble-btn-active')}>
|
<button key={i} onClick={m.action} title={m.title} className={cn('notion-bubble-btn rounded-md', m.active && 'notion-bubble-btn-active')}>
|
||||||
<m.icon className="w-3.5 h-3.5" />
|
<m.icon className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
|
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
|
||||||
<button onClick={openLinkEditor} className={cn('notion-bubble-btn', editor.isActive('link') && 'notion-bubble-btn-active')}><LinkIcon className="w-3.5 h-3.5" /></button>
|
<button onClick={openLinkEditor} className={cn('notion-bubble-btn rounded-md', editor.isActive('link') && 'notion-bubble-btn-active')}><LinkIcon className="w-3.5 h-3.5" /></button>
|
||||||
<button onClick={() => setAiOpen(!aiOpen)} className={cn('notion-bubble-btn', aiLoading && 'animate-pulse')}><Sparkles className="w-3.5 h-3.5" /></button>
|
<button onClick={() => setAiOpen(!aiOpen)} className={cn('notion-bubble-btn rounded-md', aiLoading && 'animate-pulse')}><Sparkles className="w-3.5 h-3.5" /></button>
|
||||||
{editor.isActive('image') && (
|
{editor.isActive('image') && (
|
||||||
<>
|
<>
|
||||||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
|
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
|
||||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn text-xs font-medium px-1">25%</button>
|
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">25%</button>
|
||||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn text-xs font-medium px-1">50%</button>
|
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">50%</button>
|
||||||
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn text-xs font-medium px-1">100%</button>
|
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">100%</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{aiOpen && (
|
{aiOpen && (
|
||||||
<div className="notion-ai-submenu">
|
<div className="notion-ai-submenu">
|
||||||
<button className="notion-ai-subitem" onClick={() => handleAI('clarify')}><Lightbulb className="w-3.5 h-3.5 text-amber-500" /><span>{t('richTextEditor.slashClarify')}</span></button>
|
<button className="notion-ai-subitem" onClick={() => handleAI('clarify')}><Lightbulb className="w-3.5 h-3.5 text-amber-500" /><span>{t('richTextEditor.slashClarify')}</span></button>
|
||||||
<button className="notion-ai-subitem" onClick={() => handleAI('shorten')}><Scissors className="w-3.5 h-3.5 text-blue-500" /><span>{t('richTextEditor.slashShorten')}</span></button>
|
<button className="notion-ai-subitem" onClick={() => handleAI('shorten')}><Scissors className="w-3.5 h-3.5 text-primary" /><span>{t('richTextEditor.slashShorten')}</span></button>
|
||||||
<button className="notion-ai-subitem" onClick={() => handleAI('improve')}><Wand2 className="w-3.5 h-3.5 text-purple-500" /><span>{t('richTextEditor.slashImprove')}</span></button>
|
<button className="notion-ai-subitem" onClick={() => handleAI('improve')}><Wand2 className="w-3.5 h-3.5 text-purple-500" /><span>{t('richTextEditor.slashImprove')}</span></button>
|
||||||
<button className="notion-ai-subitem" onClick={() => handleAI('fix_grammar')}><SpellCheck className="w-3.5 h-3.5 text-green-500" /><span>{t('ai.action.fixGrammar')}</span></button>
|
<button className="notion-ai-subitem" onClick={() => handleAI('fix_grammar')}><SpellCheck className="w-3.5 h-3.5 text-green-500" /><span>{t('ai.action.fixGrammar')}</span></button>
|
||||||
<button className="notion-ai-subitem" onClick={() => setTranslateOpen(v => !v)}><Languages className="w-3.5 h-3.5 text-indigo-500" /><span>{t('ai.action.translate')}</span></button>
|
<button className="notion-ai-subitem" onClick={() => setTranslateOpen(v => !v)}><Languages className="w-3.5 h-3.5 text-indigo-500" /><span>{t('ai.action.translate')}</span></button>
|
||||||
|
|||||||
@@ -4,208 +4,458 @@ import Link from 'next/link'
|
|||||||
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
FileText,
|
Settings,
|
||||||
Bell,
|
|
||||||
Archive,
|
|
||||||
Trash2,
|
|
||||||
Plus,
|
Plus,
|
||||||
|
ChevronRight,
|
||||||
|
Lock,
|
||||||
|
BookOpen,
|
||||||
|
Bot,
|
||||||
|
Inbox,
|
||||||
|
FlaskConical,
|
||||||
|
ArrowUpDown,
|
||||||
|
Archive,
|
||||||
|
MessageSquare,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
X,
|
|
||||||
Tag,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { NotebooksList } from './notebooks-list'
|
|
||||||
import { useHomeViewOptional } from '@/context/home-view-context'
|
|
||||||
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
import { useNoteRefreshOptional } from '@/context/NoteRefreshContext'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { getTrashCount } from '@/app/actions/notes'
|
import { getAllNotes } from '@/app/actions/notes'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
|
import { Notebook, Note } from '@/lib/types'
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
||||||
|
import { CreateNotebookDialog } from './create-notebook-dialog'
|
||||||
|
import { NotificationPanel } from './notification-panel'
|
||||||
|
|
||||||
const HIDDEN_ROUTES = ['/agents', '/chat', '/lab', '/admin']
|
type NavigationView = 'notebooks' | 'agents'
|
||||||
|
type SortOrder = 'newest' | 'oldest' | 'alpha'
|
||||||
|
|
||||||
export function Sidebar({ className, user }: { className?: string, user?: any }) {
|
function NoteLink({
|
||||||
|
title,
|
||||||
|
isActive,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
isActive: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg',
|
||||||
|
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'w-1.5 h-1.5 rounded-full shrink-0',
|
||||||
|
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30'
|
||||||
|
)} />
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarCarnetItem({
|
||||||
|
carnet,
|
||||||
|
isActive,
|
||||||
|
notes,
|
||||||
|
activeNoteId,
|
||||||
|
onCarnetClick,
|
||||||
|
onNoteClick,
|
||||||
|
}: {
|
||||||
|
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
||||||
|
isActive: boolean
|
||||||
|
notes: { id: string; title: string }[]
|
||||||
|
activeNoteId: string | null
|
||||||
|
onCarnetClick: () => void
|
||||||
|
onNoteClick: (noteId: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ x: 4 }}
|
||||||
|
onClick={onCarnetClick}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
|
||||||
|
isActive ? 'memento-active-nav' : 'hover:bg-white/40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: isActive ? 90 : 0 }}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</motion.div>
|
||||||
|
<div className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||||
|
isActive
|
||||||
|
? 'bg-foreground text-background border-foreground'
|
||||||
|
: 'bg-white/60 text-foreground border-border'
|
||||||
|
)}>
|
||||||
|
{carnet.initial}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn(
|
||||||
|
'text-[13px] font-medium transition-colors truncate',
|
||||||
|
isActive ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{carnet.name}
|
||||||
|
</span>
|
||||||
|
{carnet.isPrivate && <Lock size={10} className="text-muted-foreground shrink-0" />}
|
||||||
|
</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}
|
||||||
|
title={note.title}
|
||||||
|
isActive={activeNoteId === note.id}
|
||||||
|
onClick={() => onNoteClick(note.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<p className="pl-12 text-[11px] text-muted-foreground/50 py-2 italic font-light">No notes yet</p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ className, user }: { className?: string; user?: any }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const homeBridge = useHomeViewOptional()
|
|
||||||
const { refreshKey } = useNoteRefreshOptional()
|
const { refreshKey } = useNoteRefreshOptional()
|
||||||
const [trashCount, setTrashCount] = useState(0)
|
const { notebooks } = useNotebooks()
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||||
|
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string }[]>>({})
|
||||||
|
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
||||||
|
const [showSortMenu, setShowSortMenu] = useState(false)
|
||||||
|
|
||||||
const searchKey = searchParams.toString()
|
const currentNotebookId = searchParams.get('notebook')
|
||||||
|
const currentNoteId = searchParams.get('openNote')
|
||||||
|
|
||||||
// Fetch trash count — skip for hidden/admin routes to avoid dispatching a
|
// Determine if inbox is active (no notebook filter, on home page)
|
||||||
// Server Action during an ongoing App Router navigation transition, which
|
const isInboxActive =
|
||||||
// would increment React's nested-update counter and trigger Error #310.
|
pathname === '/' &&
|
||||||
|
!searchParams.get('notebook') &&
|
||||||
|
!searchParams.get('label') &&
|
||||||
|
!searchParams.get('archived') &&
|
||||||
|
!searchParams.get('trashed')
|
||||||
|
|
||||||
|
// Sync activeView with current route
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return
|
if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) {
|
||||||
getTrashCount().then(setTrashCount)
|
setActiveView('agents')
|
||||||
}, [pathname, refreshKey])
|
}
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
// Hide sidebar on Agents, Chat IA and Lab routes
|
const displayName = user?.name || user?.email || ''
|
||||||
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return null
|
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
|
||||||
|
|
||||||
// Active label filter
|
useEffect(() => {
|
||||||
const activeLabel = searchParams.get('label')
|
if (!currentNotebookId) return
|
||||||
const activeLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
if (notebookNotes[currentNotebookId]) return
|
||||||
|
|
||||||
const clearLabelFilter = () => {
|
getAllNotes(false, currentNotebookId).then(notes => {
|
||||||
const params = new URLSearchParams(searchParams)
|
const mapped = notes.map((n: Note) => ({
|
||||||
params.delete('label')
|
id: n.id,
|
||||||
|
title: getNoteDisplayTitle(n, t('notes.untitled') || 'Untitled'),
|
||||||
|
}))
|
||||||
|
setNotebookNotes(prev => ({ ...prev, [currentNotebookId!]: mapped }))
|
||||||
|
})
|
||||||
|
}, [currentNotebookId, refreshKey])
|
||||||
|
|
||||||
|
// BUG FIX: clicking a carnet always forces list (editorial) view
|
||||||
|
const handleCarnetClick = (notebookId: string) => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('notebook', notebookId)
|
||||||
|
// forceList resets to editorial view in home-client
|
||||||
|
params.set('forceList', '1')
|
||||||
router.push(`/?${params.toString()}`)
|
router.push(`/?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearLabelsFilter = (labelToRemove?: string) => {
|
const handleInboxClick = () => {
|
||||||
const params = new URLSearchParams(searchParams)
|
router.push('/?forceList=1')
|
||||||
if (labelToRemove) {
|
}
|
||||||
const remaining = activeLabels.filter(l => l !== labelToRemove)
|
|
||||||
if (remaining.length > 0) {
|
const handleNoteClick = (noteId: string) => {
|
||||||
params.set('labels', remaining.join(','))
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
} else {
|
params.set('openNote', noteId)
|
||||||
params.delete('labels')
|
params.delete('forceList')
|
||||||
}
|
|
||||||
} else {
|
|
||||||
params.delete('labels')
|
|
||||||
}
|
|
||||||
router.push(`/?${params.toString()}`)
|
router.push(`/?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to determine if a link is active
|
// Sort notebooks
|
||||||
const isActive = (href: string, exact = false) => {
|
const sortedNotebooks = [...notebooks].sort((a: Notebook, b: Notebook) => {
|
||||||
if (href === '/') {
|
if (sortOrder === 'alpha') return a.name.localeCompare(b.name)
|
||||||
// Home is active only if no special filters are applied
|
if (sortOrder === 'newest') return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
return pathname === '/' &&
|
if (sortOrder === 'oldest') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
!searchParams.get('label') &&
|
return 0
|
||||||
!searchParams.get('archived') &&
|
})
|
||||||
!searchParams.get('trashed') &&
|
|
||||||
!searchParams.get('notebook')
|
|
||||||
}
|
|
||||||
|
|
||||||
// For labels
|
const sortLabels: Record<SortOrder, string> = {
|
||||||
if (href.startsWith('/?label=')) {
|
newest: t('sidebar.sortNewest') || 'Newest first',
|
||||||
const labelParam = searchParams.get('label')
|
oldest: t('sidebar.sortOldest') || 'Oldest first',
|
||||||
// Extract label from href
|
alpha: t('sidebar.sortAlpha') || 'A → Z',
|
||||||
const labelFromHref = href.split('=')[1]
|
|
||||||
return labelParam === labelFromHref
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other routes
|
|
||||||
return pathname === href
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavItem = ({ href, icon: Icon, label, active, badge }: any) => (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-4 px-6 py-3 rounded-e-full me-2 transition-colors",
|
|
||||||
"text-[15px] font-medium tracking-wide",
|
|
||||||
active
|
|
||||||
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
|
|
||||||
: "text-muted-foreground hover:bg-muted/50 dark:hover:bg-muted/30"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className={cn("w-5 h-5", active ? "fill-current" : "")} />
|
|
||||||
<span className="truncate">{label}</span>
|
|
||||||
{badge > 0 && (
|
|
||||||
<span className={cn(
|
|
||||||
"ms-auto text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[20px] text-center",
|
|
||||||
active
|
|
||||||
? "bg-primary/20 text-primary"
|
|
||||||
: "bg-muted text-muted-foreground"
|
|
||||||
)}>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={cn(
|
<>
|
||||||
"w-[280px] flex-none flex-col bg-white dark:bg-[#1e2128] overflow-y-auto hidden md:flex py-2",
|
<aside
|
||||||
className
|
className={cn(
|
||||||
)}>
|
'hidden h-full min-h-0 w-80 shrink-0 flex-col md:flex',
|
||||||
{/* Main Navigation */}
|
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-border/30 dark:bg-sidebar/90',
|
||||||
<div className="flex flex-col gap-1 px-3">
|
className
|
||||||
<NavItem
|
)}
|
||||||
href="/"
|
>
|
||||||
icon={FileText}
|
{/* ── Top: Avatar + View Toggle ── */}
|
||||||
label={t('sidebar.notes') || 'Notes'}
|
<div className="p-6 flex items-center justify-between mb-4">
|
||||||
active={isActive('/')}
|
{/* Avatar → profile */}
|
||||||
/>
|
<Link href="/settings/profile" className="shrink-0">
|
||||||
</div>
|
<div className="w-10 h-10 rounded-full bg-slate-200 border border-border flex items-center justify-center text-foreground font-memento-serif text-lg shadow-sm hover:ring-2 hover:ring-primary/30 transition-all">
|
||||||
|
{user?.image ? (
|
||||||
|
<Avatar className="size-10 ring-1 ring-border/60">
|
||||||
{/* Notebooks Section */}
|
<AvatarImage src={user.image} alt="" />
|
||||||
<div className="flex flex-col mt-2">
|
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">{initial}</AvatarFallback>
|
||||||
<NotebooksList />
|
</Avatar>
|
||||||
</div>
|
) : (
|
||||||
|
<span>{initial}</span>
|
||||||
{/* Active Label Filter Chips */}
|
)}
|
||||||
{pathname === '/' && (activeLabel || activeLabels.length > 0) && (
|
|
||||||
<div className="px-4 pt-2 flex flex-col gap-1">
|
|
||||||
{activeLabel && (
|
|
||||||
<div className="flex items-center gap-2 ps-2 pe-1 py-1.5 rounded-e-full me-2 bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground">
|
|
||||||
<Tag className="w-3.5 h-3.5 shrink-0" />
|
|
||||||
<span className="text-xs font-medium truncate flex-1">{activeLabel}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={clearLabelFilter}
|
|
||||||
className="shrink-0 p-0.5 rounded-full hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors"
|
|
||||||
title={t('sidebar.clearFilter') || 'Remove filter'}
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Link>
|
||||||
{activeLabels.map((label) => (
|
|
||||||
<div
|
{/* Notebooks / Agents toggle */}
|
||||||
key={label}
|
<div className="sidebar-view-toggle">
|
||||||
className="flex items-center gap-2 ps-2 pe-1 py-1.5 rounded-e-full me-2 bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground"
|
<button
|
||||||
|
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
|
||||||
|
className={cn('sidebar-view-toggle-btn', activeView === 'notebooks' && 'active')}
|
||||||
|
title={t('nav.notebooks') || 'Notebooks'}
|
||||||
>
|
>
|
||||||
<Tag className="w-3.5 h-3.5 shrink-0" />
|
<BookOpen size={14} />
|
||||||
<span className="text-xs font-medium truncate flex-1">{label}</span>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
onClick={() => { setActiveView('agents'); router.push('/agents') }}
|
||||||
onClick={() => clearLabelsFilter(label)}
|
className={cn('sidebar-view-toggle-btn', activeView === 'agents' && 'active')}
|
||||||
className="shrink-0 p-0.5 rounded-full hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors"
|
title={t('nav.agents') || 'Agents'}
|
||||||
title={t('sidebar.clearFilter') || 'Remove filter'}
|
>
|
||||||
>
|
<Bot size={14} />
|
||||||
<X className="w-3 h-3" />
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Archive & Trash */}
|
{/* ── Scrollable content ── */}
|
||||||
<div className="flex flex-col mt-auto pb-4 border-t border-transparent">
|
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar pb-4">
|
||||||
<NavItem
|
|
||||||
href="/reminders"
|
|
||||||
icon={Bell}
|
|
||||||
label={t('sidebar.reminders') || 'Rappels'}
|
|
||||||
active={isActive('/reminders')}
|
|
||||||
/>
|
|
||||||
<NavItem
|
|
||||||
href="/archive"
|
|
||||||
icon={Archive}
|
|
||||||
label={t('sidebar.archive') || 'Archives'}
|
|
||||||
active={pathname === '/archive'}
|
|
||||||
/>
|
|
||||||
<NavItem
|
|
||||||
href="/trash"
|
|
||||||
icon={Trash2}
|
|
||||||
label={t('sidebar.trash') || 'Corbeille'}
|
|
||||||
active={pathname === '/trash'}
|
|
||||||
badge={trashCount}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</aside>
|
<AnimatePresence mode="wait">
|
||||||
|
{activeView === 'notebooks' ? (
|
||||||
|
<motion.div
|
||||||
|
key="notebooks"
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{/* Section header with sort button */}
|
||||||
|
<div className="flex items-center justify-between px-4 mb-3">
|
||||||
|
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
|
||||||
|
{t('nav.notebooks') || 'Notebooks'}
|
||||||
|
</p>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSortMenu(s => !s)}
|
||||||
|
className="p-1 text-muted-foreground hover:text-foreground transition-colors rounded"
|
||||||
|
title={t('sidebar.sortOrder') || 'Sort order'}
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={12} />
|
||||||
|
</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-1 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>
|
||||||
|
|
||||||
|
{/* Inbox — Notes without notebook */}
|
||||||
|
<button
|
||||||
|
onClick={handleInboxClick}
|
||||||
|
className={cn('sidebar-inbox-item', isInboxActive && 'active')}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||||
|
isInboxActive
|
||||||
|
? 'bg-foreground text-background border-foreground'
|
||||||
|
: 'bg-white/60 text-foreground border-border'
|
||||||
|
)}>
|
||||||
|
<Inbox size={14} />
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
'text-[13px] font-medium truncate',
|
||||||
|
isInboxActive ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{t('sidebar.inbox') || 'Inbox'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="mx-4 my-3 h-px bg-border/40" />
|
||||||
|
|
||||||
|
{/* Notebooks list */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sortedNotebooks.map((notebook: Notebook) => {
|
||||||
|
const isActive = currentNotebookId === notebook.id
|
||||||
|
const notes = notebookNotes[notebook.id] || []
|
||||||
|
return (
|
||||||
|
<SidebarCarnetItem
|
||||||
|
key={notebook.id}
|
||||||
|
carnet={{
|
||||||
|
id: notebook.id,
|
||||||
|
name: notebook.name,
|
||||||
|
initial: notebook.name.charAt(0).toUpperCase(),
|
||||||
|
}}
|
||||||
|
isActive={isActive}
|
||||||
|
notes={isActive ? notes : []}
|
||||||
|
activeNoteId={currentNoteId}
|
||||||
|
onCarnetClick={() => handleCarnetClick(notebook.id)}
|
||||||
|
onNoteClick={handleNoteClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateDialogOpen(true)}
|
||||||
|
className="w-full mt-4 flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/40"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
<span>{t('notebooks.create') || 'New Carnet'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="agents"
|
||||||
|
initial={{ opacity: 0, x: 10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -10 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
|
||||||
|
{t('agents.intelligenceOS') || 'Intelligence OS'}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[
|
||||||
|
{ id: 'agents', href: '/agents', label: t('agents.myAgents') || 'Mes Agents', icon: Bot },
|
||||||
|
{ id: 'lab', href: '/lab', label: t('nav.lab') || 'Le Lab AI', icon: FlaskConical },
|
||||||
|
{ id: 'chat', href: '/chat', label: t('nav.chat') || 'Conversations', icon: MessageSquare },
|
||||||
|
].map(item => {
|
||||||
|
const isActive = pathname.startsWith(item.href)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
|
||||||
|
isActive ? 'memento-active-nav' : 'text-muted-foreground hover:bg-white/40 hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
|
||||||
|
isActive
|
||||||
|
? 'bg-foreground text-background border-foreground'
|
||||||
|
: 'bg-white/60 border-border group-hover:border-foreground/20'
|
||||||
|
)}>
|
||||||
|
<item.icon size={16} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[13px] font-medium">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* General Chat button (opens floating panel) */}
|
||||||
|
<button
|
||||||
|
onClick={() => window.dispatchEvent(new Event('toggle-ai-chat'))}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group text-muted-foreground hover:bg-white/40 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0 bg-white/60 border-border group-hover:border-foreground/20">
|
||||||
|
<Sparkles size={16} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[13px] font-medium">{t('ai.openAssistant') || 'Assistant IA'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Footer ── */}
|
||||||
|
<div className="pt-4 p-5 border-t border-border space-y-1">
|
||||||
|
{/* Notification bell */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2">
|
||||||
|
<NotificationPanel />
|
||||||
|
<span className="text-[13px] text-muted-foreground font-medium">{t('notification.notifications') || 'Notifications'}</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/archive"
|
||||||
|
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
|
||||||
|
>
|
||||||
|
<Archive size={16} />
|
||||||
|
<span>{t('sidebar.archive') || 'Archive'}</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="flex items-center gap-3 px-4 py-2 text-[13px] text-muted-foreground hover:text-foreground transition-colors font-medium rounded-lg hover:bg-white/30"
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
<span>{t('nav.settings') || 'Settings'}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<CreateNotebookDialog
|
||||||
|
open={isCreateDialogOpen}
|
||||||
|
onOpenChange={setIsCreateDialogOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,61 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { applyDocumentTheme, normalizeThemeId } from '@/lib/apply-document-theme'
|
||||||
|
|
||||||
interface ThemeInitializerProps {
|
interface ThemeInitializerProps {
|
||||||
theme?: string
|
theme?: string
|
||||||
fontSize?: string
|
fontSize?: string
|
||||||
fontFamily?: string
|
fontFamily?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeInitializer({ theme, fontSize, fontFamily }: ThemeInitializerProps) {
|
export function ThemeInitializer({ theme, fontSize, fontFamily }: ThemeInitializerProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const applyFontSize = (s?: string) => {
|
||||||
|
const size = s || 'medium'
|
||||||
|
|
||||||
// Helper to apply theme
|
const fontSizeMap: Record<string, string> = {
|
||||||
const applyTheme = (t?: string) => {
|
small: '14px',
|
||||||
|
medium: '16px',
|
||||||
|
large: '18px',
|
||||||
|
'extra-large': '20px',
|
||||||
|
}
|
||||||
|
|
||||||
if (!t) return
|
const fontSizeFactorMap: Record<string, number> = {
|
||||||
|
small: 0.95,
|
||||||
|
medium: 1.0,
|
||||||
|
large: 1.1,
|
||||||
|
'extra-large': 1.25,
|
||||||
|
}
|
||||||
|
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
|
root.style.setProperty('--user-font-size', fontSizeMap[size] || '16px')
|
||||||
|
root.style.setProperty('--user-font-size-factor', (fontSizeFactorMap[size] || 1).toString())
|
||||||
|
}
|
||||||
|
|
||||||
// Reset
|
const localTheme = localStorage.getItem('theme-preference')
|
||||||
root.removeAttribute('data-theme')
|
const effectiveTheme = localTheme || theme || 'light'
|
||||||
root.classList.remove('dark')
|
|
||||||
|
|
||||||
if (t === 'auto') {
|
applyDocumentTheme(effectiveTheme)
|
||||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
if (systemDark) root.classList.add('dark')
|
|
||||||
} else if (t === 'dark') {
|
|
||||||
root.classList.add('dark')
|
|
||||||
} else if (t === 'light') {
|
|
||||||
// Default, nothing needed usually if light is default, but ensuring no 'dark' class
|
|
||||||
} else {
|
|
||||||
// Named theme
|
|
||||||
root.setAttribute('data-theme', t)
|
|
||||||
// Check if theme implies dark mode (e.g. midnight)
|
|
||||||
if (['midnight'].includes(t)) {
|
|
||||||
root.classList.add('dark')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to apply font size
|
if (!localTheme && theme) {
|
||||||
const applyFontSize = (s?: string) => {
|
localStorage.setItem('theme-preference', normalizeThemeId(theme))
|
||||||
const size = s || 'medium'
|
}
|
||||||
|
|
||||||
const fontSizeMap: Record<string, string> = {
|
applyFontSize(fontSize)
|
||||||
'small': '14px',
|
|
||||||
'medium': '16px',
|
|
||||||
'large': '18px',
|
|
||||||
'extra-large': '20px'
|
|
||||||
}
|
|
||||||
|
|
||||||
const fontSizeFactorMap: Record<string, number> = {
|
const localFontFamily = localStorage.getItem('font-family')
|
||||||
'small': 0.95,
|
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
|
||||||
'medium': 1.0,
|
const root = document.documentElement
|
||||||
'large': 1.1,
|
if (effectiveFontFamily === 'system') {
|
||||||
'extra-large': 1.25
|
root.classList.add('font-system')
|
||||||
}
|
} else {
|
||||||
|
root.classList.remove('font-system')
|
||||||
|
}
|
||||||
|
if (!localFontFamily && fontFamily) {
|
||||||
|
localStorage.setItem('font-family', fontFamily)
|
||||||
|
}
|
||||||
|
}, [theme, fontSize, fontFamily])
|
||||||
|
|
||||||
const root = document.documentElement
|
return null
|
||||||
root.style.setProperty('--user-font-size', fontSizeMap[size] || '16px')
|
|
||||||
root.style.setProperty('--user-font-size-factor', (fontSizeFactorMap[size] || 1).toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRITICAL: Use localStorage as the source of truth (it's always fresh)
|
|
||||||
// Server prop may be stale due to caching.
|
|
||||||
const localTheme = localStorage.getItem('theme-preference')
|
|
||||||
const effectiveTheme = localTheme || theme
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
applyTheme(effectiveTheme)
|
|
||||||
|
|
||||||
// Only sync to localStorage if it was empty (first visit after login)
|
|
||||||
// NEVER overwrite with server value if localStorage already has a value
|
|
||||||
if (!localTheme && theme) {
|
|
||||||
localStorage.setItem('theme-preference', theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyFontSize(fontSize)
|
|
||||||
|
|
||||||
// Apply font family
|
|
||||||
const localFontFamily = localStorage.getItem('font-family')
|
|
||||||
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
|
|
||||||
const root = document.documentElement
|
|
||||||
if (effectiveFontFamily === 'system') {
|
|
||||||
root.classList.add('font-system')
|
|
||||||
} else {
|
|
||||||
root.classList.remove('font-system')
|
|
||||||
}
|
|
||||||
if (!localFontFamily && fontFamily) {
|
|
||||||
localStorage.setItem('font-family', fontFamily)
|
|
||||||
}
|
|
||||||
}, [theme, fontSize, fontFamily])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
|
|||||||
if (suggestions.length === 0) return null
|
if (suggestions.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 p-3 bg-sky-50 dark:bg-sky-950/50 border border-sky-200 dark:border-sky-800 rounded-lg animate-in fade-in slide-in-from-top-2 duration-300">
|
<div className="mt-2 p-3 bg-stone-100/90 dark:bg-stone-900/50 border border-stone-200 dark:border-stone-700 rounded-lg animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-sky-900 dark:text-sky-100">
|
<div className="flex items-center gap-2 text-sm font-medium text-stone-800 dark:text-stone-100">
|
||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
<span>{t('titleSuggestions.title')}</span>
|
<span>{t('titleSuggestions.title')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
className="text-sky-500 hover:text-sky-900 dark:text-sky-400 dark:hover:text-sky-200 transition-colors"
|
className="text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -38,19 +38,19 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
|
|||||||
onClick={() => onSelect(suggestion.title)}
|
onClick={() => onSelect(suggestion.title)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left px-3 py-2 rounded-md transition-all",
|
"w-full text-left px-3 py-2 rounded-md transition-all",
|
||||||
"hover:bg-sky-100 dark:hover:bg-sky-900/50",
|
"hover:bg-stone-200/80 dark:hover:bg-stone-800/50",
|
||||||
"text-sm text-sky-900 dark:text-sky-100",
|
"text-sm text-stone-800 dark:text-stone-100",
|
||||||
"border border-transparent hover:border-sky-300 dark:hover:border-sky-700"
|
"border border-transparent hover:border-stone-300 dark:hover:border-stone-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="font-medium">{suggestion.title}</span>
|
<span className="font-medium">{suggestion.title}</span>
|
||||||
<span className="text-xs text-sky-500 dark:text-sky-400 whitespace-nowrap">
|
<span className="text-xs text-stone-500 dark:text-stone-400 whitespace-nowrap">
|
||||||
{suggestion.confidence}%
|
{suggestion.confidence}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{suggestion.reasoning && (
|
{suggestion.reasoning && (
|
||||||
<p className="text-xs text-sky-600 dark:text-sky-300 mt-1">
|
<p className="text-xs text-stone-600 dark:text-stone-300 mt-1">
|
||||||
{suggestion.reasoning}
|
{suggestion.reasoning}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { Slot } from "radix-ui"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-normal transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-normal transition-all duration-200 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground shadow-[0_1px_2px_rgba(0,0,0,0.06),0_2px_4px_rgba(0,0,0,0.04)] hover:bg-primary/90 hover:shadow-[0_2px_8px_rgba(0,0,0,0.08),0_4px_8px_rgba(0,0,0,0.06)]",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
"bg-destructive text-white shadow-[0_1px_2px_rgba(0,0,0,0.06)] hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-lg border border-border/40 py-6 shadow-[0_1px_2px_rgba(0,0,0,0.06),0_2px_4px_rgba(0,0,0,0.04)]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/50 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/50 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow,border-color] duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-primary/40 focus-visible:ring-primary/20 focus-visible:ring-[3px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,24 +5,34 @@ import { Toaster as SonnerToaster, toast as sonnerToast } from 'sonner'
|
|||||||
// Re-export toast functions from Sonner
|
// Re-export toast functions from Sonner
|
||||||
export const toast = sonnerToast
|
export const toast = sonnerToast
|
||||||
|
|
||||||
// Toaster component with custom styles
|
// Toaster component — styled for Memento paper/ink design system
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
return (
|
return (
|
||||||
<SonnerToaster
|
<SonnerToaster
|
||||||
position="bottom-right"
|
position="bottom-right"
|
||||||
expand={false}
|
expand={false}
|
||||||
richColors
|
richColors={false}
|
||||||
closeButton
|
closeButton
|
||||||
duration={3000}
|
duration={3500}
|
||||||
className="toaster"
|
className="toaster font-sans"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast: 'toast pointer-events-auto',
|
toast: [
|
||||||
description: 'toast-description',
|
'toast pointer-events-auto',
|
||||||
actionButton: 'toast-action-button',
|
'!bg-[#1C1C1C] !text-[#F2F0E9] !border !border-white/10',
|
||||||
closeButton: 'toast-close-button',
|
'!rounded-xl !shadow-xl !shadow-black/30',
|
||||||
|
'!text-[13px] !font-medium !py-3 !px-4',
|
||||||
|
].join(' '),
|
||||||
|
description: '!text-[#F2F0E9]/70 !text-[12px]',
|
||||||
|
actionButton: '!bg-[#F2F0E9] !text-[#1C1C1C] !text-[11px] !font-bold !rounded-lg !px-3 !py-1',
|
||||||
|
closeButton: '!bg-white/10 !text-[#F2F0E9]/70 !border-white/10 hover:!bg-white/20',
|
||||||
|
success: '!border-l-4 !border-l-emerald-400/70',
|
||||||
|
error: '!border-l-4 !border-l-red-400/70',
|
||||||
|
warning: '!border-l-4 !border-l-amber-400/70',
|
||||||
|
info: '!border-l-4 !border-l-sky-400/70',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
83
memento-note/lib/apply-document-theme.ts
Normal file
83
memento-note/lib/apply-document-theme.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Valeurs persistées pour User.theme / localStorage `theme-preference`.
|
||||||
|
* Une seule source de vérité pour le DOM : évite les écarts entre header, settings et hydratation.
|
||||||
|
*/
|
||||||
|
export const THEME_IDS = [
|
||||||
|
'light',
|
||||||
|
'dark',
|
||||||
|
'auto',
|
||||||
|
'sepia',
|
||||||
|
'midnight',
|
||||||
|
'rose',
|
||||||
|
'green',
|
||||||
|
'lavender',
|
||||||
|
'sand',
|
||||||
|
'ocean',
|
||||||
|
'sunset',
|
||||||
|
'blue',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ThemeId = (typeof THEME_IDS)[number]
|
||||||
|
|
||||||
|
const NAMED_PALETTE_IDS: ThemeId[] = [
|
||||||
|
'sepia',
|
||||||
|
'midnight',
|
||||||
|
'rose',
|
||||||
|
'green',
|
||||||
|
'lavender',
|
||||||
|
'sand',
|
||||||
|
'ocean',
|
||||||
|
'sunset',
|
||||||
|
'blue',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function isThemeId(value: string | null | undefined): value is ThemeId {
|
||||||
|
return value !== undefined && value !== null && (THEME_IDS as readonly string[]).includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corrige les anciennes valeurs (ex. formulaire « slate ») vers un thème supporté. */
|
||||||
|
export function normalizeThemeId(raw: string | null | undefined): ThemeId {
|
||||||
|
if (!raw) return 'light'
|
||||||
|
if (raw === 'slate') return 'light'
|
||||||
|
if (isThemeId(raw)) return raw
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique le thème sur `<html>` : `class="dark"` et/ou `data-theme`.
|
||||||
|
* - `light` → papier par défaut (`:root`), pas de `data-theme`
|
||||||
|
* - `dark` → sombre global (`.dark`)
|
||||||
|
* - `auto` → `.dark` si prefers-color-scheme: dark
|
||||||
|
* - palettes nommées → `data-theme="<id>"` ; `midnight` force aussi `.dark` (variante sombre du thème)
|
||||||
|
*/
|
||||||
|
export function applyDocumentTheme(theme: string): void {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
|
||||||
|
const t = normalizeThemeId(theme)
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.remove('dark')
|
||||||
|
root.removeAttribute('data-theme')
|
||||||
|
|
||||||
|
if (t === 'auto') {
|
||||||
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
root.classList.add('dark')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t === 'dark') {
|
||||||
|
root.classList.add('dark')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t === 'light') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((NAMED_PALETTE_IDS as readonly string[]).includes(t)) {
|
||||||
|
root.setAttribute('data-theme', t)
|
||||||
|
if (t === 'midnight') {
|
||||||
|
root.classList.add('dark')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
memento-note/lib/client/excalidraw-export-image.ts
Normal file
59
memento-note/lib/client/excalidraw-export-image.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
|
||||||
|
import type { BinaryFiles } from '@excalidraw/excalidraw/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses canvas JSON from DB — either a bare elements array or { elements, files }.
|
||||||
|
* Returns null for PPTX placeholders or invalid JSON.
|
||||||
|
*/
|
||||||
|
export function parseExcalidrawSceneFromCanvasData(
|
||||||
|
dataStr: string
|
||||||
|
): { elements: readonly ExcalidrawElement[]; files: BinaryFiles | null } | null {
|
||||||
|
if (!dataStr?.trim()) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(dataStr)
|
||||||
|
if (parsed && parsed.type === 'pptx') return null
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return { elements: parsed as ExcalidrawElement[], files: null }
|
||||||
|
}
|
||||||
|
if (parsed?.elements && Array.isArray(parsed.elements)) {
|
||||||
|
const files =
|
||||||
|
parsed.files && typeof parsed.files === 'object' ? (parsed.files as BinaryFiles) : null
|
||||||
|
return { elements: parsed.elements as readonly ExcalidrawElement[], files }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders Excali scene to PNG in the browser (uses Excalidraw export helpers).
|
||||||
|
*/
|
||||||
|
export async function exportExcalidrawSceneToPngBlob(dataStr: string): Promise<Blob | null> {
|
||||||
|
const scene = parseExcalidrawSceneFromCanvasData(dataStr)
|
||||||
|
if (!scene || scene.elements.length === 0) return null
|
||||||
|
|
||||||
|
const { exportToBlob, getNonDeletedElements, MIME_TYPES } = await import('@excalidraw/excalidraw')
|
||||||
|
const elements = getNonDeletedElements(scene.elements as ExcalidrawElement[])
|
||||||
|
if (!elements.length) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await exportToBlob({
|
||||||
|
elements,
|
||||||
|
files: scene.files,
|
||||||
|
mimeType: MIME_TYPES.png,
|
||||||
|
exportPadding: 24,
|
||||||
|
maxWidthOrHeight: 2400,
|
||||||
|
appState: {
|
||||||
|
exportBackground: true,
|
||||||
|
exportWithDarkMode: false,
|
||||||
|
viewBackgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[excalidraw-export-image] exportToBlob failed:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import type { Note } from '@/lib/types'
|
||||||
|
|
||||||
|
const MD_IMG = /!\[[^\]]*\]\(([^)\s]+)\)/g
|
||||||
|
const HTML_IMG = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plain-text preview for list view (light markdown stripping).
|
* Plain-text preview for list view (light markdown stripping).
|
||||||
*/
|
*/
|
||||||
@@ -31,3 +36,27 @@ export function getNoteDisplayTitle(note: { title: string | null; content: strin
|
|||||||
const preview = stripMarkdownPreview(note.content || '', 100)
|
const preview = stripMarkdownPreview(note.content || '', 100)
|
||||||
return preview || untitled
|
return preview || untitled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getNoteFeedImage(note: Note): string | null {
|
||||||
|
const first = note.images?.[0]?.trim()
|
||||||
|
if (first) return first
|
||||||
|
|
||||||
|
const content = note.content || ''
|
||||||
|
MD_IMG.lastIndex = 0
|
||||||
|
const md = MD_IMG.exec(content)
|
||||||
|
if (md?.[1]) return md[1]
|
||||||
|
|
||||||
|
HTML_IMG.lastIndex = 0
|
||||||
|
const html = HTML_IMG.exec(content)
|
||||||
|
if (html?.[1]) return html[1]
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain excerpt for collection cards (HTML + markdown noise stripped). */
|
||||||
|
export function getNotePlainExcerpt(note: Note, maxLen = 240): string {
|
||||||
|
let raw = note.content || ''
|
||||||
|
raw = raw.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
||||||
|
raw = raw.replace(/<[^>]+>/g, ' ')
|
||||||
|
return stripMarkdownPreview(raw, maxLen)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
export function getThemeScript(theme: string = 'light') {
|
/**
|
||||||
|
* Script inline exécuté avant l’hydratation : même logique que `applyDocumentTheme`.
|
||||||
|
*/
|
||||||
|
import { normalizeThemeId } from './apply-document-theme'
|
||||||
|
|
||||||
|
export function getThemeScript(serverTheme: string = 'light') {
|
||||||
|
const fallback = normalizeThemeId(serverTheme)
|
||||||
return `
|
return `
|
||||||
(function() {
|
(function() {
|
||||||
try {
|
try {
|
||||||
var localTheme = localStorage.getItem('theme-preference');
|
var fallback = ${JSON.stringify(fallback)};
|
||||||
var theme = localTheme || '${theme}';
|
var stored = localStorage.getItem('theme-preference');
|
||||||
var root = document.documentElement;
|
var raw = stored || fallback;
|
||||||
|
if (raw === 'slate') raw = 'light';
|
||||||
root.classList.remove('dark');
|
var allowed = { light:1, dark:1, auto:1, sepia:1, midnight:1, rose:1, green:1, lavender:1, sand:1, ocean:1, sunset:1, blue:1 };
|
||||||
root.removeAttribute('data-theme');
|
var theme = allowed[raw] ? raw : 'light';
|
||||||
|
var root = document.documentElement;
|
||||||
if (theme === 'auto') {
|
root.classList.remove('dark');
|
||||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
root.removeAttribute('data-theme');
|
||||||
root.classList.add('dark');
|
if (theme === 'auto') {
|
||||||
}
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark');
|
||||||
} else if (theme === 'dark') {
|
} else if (theme === 'dark') {
|
||||||
root.classList.add('dark');
|
root.classList.add('dark');
|
||||||
} else if (theme === 'light') {
|
} else if (theme === 'light') {
|
||||||
// do nothing
|
/* :root papier */
|
||||||
} else {
|
} else {
|
||||||
root.setAttribute('data-theme', theme);
|
root.setAttribute('data-theme', theme);
|
||||||
if (theme === 'midnight') {
|
if (theme === 'midnight') root.classList.add('dark');
|
||||||
root.classList.add('dark');
|
}
|
||||||
}
|
} catch (e) {
|
||||||
}
|
console.error('Theme script error', e);
|
||||||
} catch (e) {
|
}
|
||||||
console.error('Theme script error', e);
|
})();
|
||||||
}
|
`.trim()
|
||||||
})();
|
|
||||||
`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,10 +154,10 @@ export const LABEL_COLORS = {
|
|||||||
icon: 'text-teal-500 dark:text-teal-400'
|
icon: 'text-teal-500 dark:text-teal-400'
|
||||||
},
|
},
|
||||||
blue: {
|
blue: {
|
||||||
bg: 'bg-sky-100 dark:bg-sky-900/40',
|
bg: 'bg-slate-100 dark:bg-slate-900/40',
|
||||||
text: 'text-sky-700 dark:text-sky-300',
|
text: 'text-slate-700 dark:text-slate-300',
|
||||||
border: 'border-sky-200 dark:border-sky-800',
|
border: 'border-slate-200 dark:border-slate-700',
|
||||||
icon: 'text-sky-500 dark:text-sky-400'
|
icon: 'text-slate-600 dark:text-slate-400'
|
||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
bg: 'bg-violet-100 dark:bg-violet-900/40',
|
bg: 'bg-violet-100 dark:bg-violet-900/40',
|
||||||
@@ -207,9 +207,9 @@ export const NOTE_COLORS = {
|
|||||||
card: 'bg-teal-50 dark:bg-teal-950/30 border-teal-100 dark:border-teal-900/50'
|
card: 'bg-teal-50 dark:bg-teal-950/30 border-teal-100 dark:border-teal-900/50'
|
||||||
},
|
},
|
||||||
blue: {
|
blue: {
|
||||||
bg: 'bg-blue-50 dark:bg-blue-950/30',
|
bg: 'bg-slate-50 dark:bg-slate-950/30',
|
||||||
hover: 'hover:bg-blue-100 dark:hover:bg-blue-950/50',
|
hover: 'hover:bg-slate-100 dark:hover:bg-slate-950/50',
|
||||||
card: 'bg-blue-50 dark:bg-blue-950/30 border-blue-100 dark:border-blue-900/50'
|
card: 'bg-slate-50 dark:bg-slate-950/30 border-slate-200 dark:border-slate-800/50'
|
||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
bg: 'bg-purple-50 dark:bg-purple-950/30',
|
bg: 'bg-purple-50 dark:bg-purple-950/30',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user