Compare commits
26 Commits
ancien-ui
...
feature/ar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbca93c4be | ||
|
|
368b43cb8e | ||
|
|
66e957fd59 | ||
|
|
b0c2556a12 | ||
|
|
60a3fe5453 | ||
|
|
1446463f04 | ||
|
|
97b08e5d0b | ||
|
|
574c8b3166 | ||
|
|
9b8df398dc | ||
|
|
65568c0f07 | ||
|
|
def683982c | ||
|
|
91b1201112 | ||
|
|
a58610003d | ||
|
|
29e65038b7 | ||
|
|
38c637cfac | ||
|
|
3b036e84b8 | ||
|
|
ea62d68cdd | ||
|
|
77d6458946 | ||
|
|
8d4e4d5d56 | ||
|
|
24b5d6bdac | ||
|
|
01390ebb5b | ||
|
|
db899b0da2 | ||
|
|
7bc63158bf | ||
|
|
ccbd1b5abc | ||
|
|
df943878a0 | ||
|
|
e458b63115 |
10
.claude/settings.json
Normal file
10
.claude/settings.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(curl -s http://localhost:3000)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/)",
|
||||||
|
"Bash(kill 3309513)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,14 @@
|
|||||||
"Bash(do python3 -c \"import json; json.load\\(open\\(''$f''\\)\\)\")",
|
"Bash(do python3 -c \"import json; json.load\\(open\\(''$f''\\)\\)\")",
|
||||||
"Bash(done)",
|
"Bash(done)",
|
||||||
"Bash(npx prisma generate)",
|
"Bash(npx prisma generate)",
|
||||||
"Bash(git add:*)"
|
"Bash(git add:*)",
|
||||||
|
"Bash(npm list *)",
|
||||||
|
"Bash(git commit -m ' *)",
|
||||||
|
"Bash(git push *)",
|
||||||
|
"mcp__zai-mcp-server__analyze_image",
|
||||||
|
"Bash(npx prisma *)",
|
||||||
|
"Bash(xargs -I{} ls {})",
|
||||||
|
"Bash(node_modules/.bin/tsc --noEmit)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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-grid (5)/.env.example
Normal file
9
architectural-grid (5)/.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 (5)/.gitignore
vendored
Normal file
8
architectural-grid (5)/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
architectural-grid (5)/README.md
Normal file
20
architectural-grid (5)/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 (5)/index.html
Normal file
13
architectural-grid (5)/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 (5)/metadata.json
Normal file
6
architectural-grid (5)/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 (5)/package.json
Normal file
34
architectural-grid (5)/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1352
architectural-grid (5)/src/App.tsx
Normal file
1352
architectural-grid (5)/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
58
architectural-grid (5)/src/index.css
Normal file
58
architectural-grid (5)/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 (5)/src/main.tsx
Normal file
10
architectural-grid (5)/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 (5)/tsconfig.json
Normal file
26
architectural-grid (5)/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 (5)/vite.config.ts
Normal file
24
architectural-grid (5)/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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -22,26 +22,36 @@ NEXTAUTH_URL="http://localhost:3000"
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# AI Providers
|
# AI Providers
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Main provider: "openai" | "ollama" | "deepseek" | "openrouter" | "custom-openai"
|
# Main provider: "openai" | "anthropic" | "anthropic_custom" | "ollama" | "deepseek" | "openrouter" | "custom-openai"
|
||||||
# AI_PROVIDER="openai"
|
# AI_PROVIDER="openai"
|
||||||
|
|
||||||
# Per-feature provider overrides (optional, falls back to AI_PROVIDER)
|
# Per-feature provider overrides (optional, falls back to AI_PROVIDER)
|
||||||
# AI_PROVIDER_CHAT="openai"
|
# AI_PROVIDER_CHAT="openai"
|
||||||
# AI_PROVIDER_TAGS="openai"
|
# AI_PROVIDER_TAGS="anthropic"
|
||||||
# AI_PROVIDER_EMBEDDING="openai"
|
# AI_PROVIDER_EMBEDDING="openai"
|
||||||
|
|
||||||
# Model names (optional, uses provider defaults)
|
# Model names (optional, uses provider defaults)
|
||||||
# AI_MODEL_CHAT="gpt-4o-mini"
|
# AI_MODEL_CHAT="gpt-4o-mini"
|
||||||
# AI_MODEL_TAGS="gpt-4o-mini"
|
# AI_MODEL_TAGS="claude-sonnet-4-20250514"
|
||||||
# AI_MODEL_EMBEDDING="text-embedding-3-small"
|
# AI_MODEL_EMBEDDING="text-embedding-3-small"
|
||||||
|
|
||||||
# OpenAI
|
# OpenAI
|
||||||
# OPENAI_API_KEY="sk-..."
|
# OPENAI_API_KEY="sk-..."
|
||||||
|
|
||||||
|
# Anthropic (official Messages API — tags/chat only; use another provider for embeddings)
|
||||||
|
# ANTHROPIC_API_KEY="sk-ant-api03-..."
|
||||||
|
|
||||||
|
# Anthropic-compatible Messages API (custom host — ex. MiniMax M2.7, pas OpenAI)
|
||||||
|
# Same key as sur https://platform.minimax.io — base URL sans slash final.
|
||||||
|
# ANTHROPIC_CUSTOM_API_KEY="<MINIMAX_API_KEY>"
|
||||||
|
# ANTHROPIC_CUSTOM_BASE_URL="https://api.minimax.io/anthropic"
|
||||||
|
# China: https://api.minimaxi.com/anthropic — Model ID admin: MiniMax-M2.7
|
||||||
|
# Embeddings MiniMax: utiliser CUSTOM_* avec https://api.minimax.io/v1
|
||||||
|
|
||||||
# Ollama (local)
|
# Ollama (local)
|
||||||
# OLLAMA_BASE_URL="http://localhost:11434"
|
# OLLAMA_BASE_URL="http://localhost:11434"
|
||||||
|
|
||||||
# Custom OpenAI-compatible endpoint
|
# Custom OpenAI-compatible endpoint (incl. MiniMax OpenAI API /v1)
|
||||||
# CUSTOM_OPENAI_API_KEY="..."
|
# CUSTOM_OPENAI_API_KEY="..."
|
||||||
# CUSTOM_OPENAI_BASE_URL="https://your-provider.com/v1"
|
# CUSTOM_OPENAI_BASE_URL="https://your-provider.com/v1"
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
@@ -103,9 +103,9 @@ export default function AITestPage() {
|
|||||||
|
|
||||||
{/* 3. Chat Test - Horizontal Layout */}
|
{/* 3. Chat Test - Horizontal Layout */}
|
||||||
<div className="bg-card rounded-[4rem] border border-border/60 shadow-xl overflow-hidden hover:shadow-2xl transition-all duration-700 group flex flex-col xl:flex-row">
|
<div className="bg-card rounded-[4rem] border border-border/60 shadow-xl overflow-hidden hover:shadow-2xl transition-all duration-700 group flex flex-col xl:flex-row">
|
||||||
<div className="xl:w-1/3 p-12 md:p-16 border-b xl:border-b-0 xl:border-r border-border/40 bg-gradient-to-br from-violet-500/[0.05] to-transparent relative overflow-hidden">
|
<div className="xl:w-1/3 p-12 md:p-16 border-b xl:border-b-0 xl:border-r border-border/40 bg-gradient-to-br from-zinc-500/[0.05] to-transparent relative overflow-hidden">
|
||||||
<div className="absolute -right-10 -bottom-10 opacity-[0.03] group-hover:opacity-[0.08] transition-all duration-700 group-hover:scale-125 group-hover:-rotate-6">
|
<div className="absolute -right-10 -bottom-10 opacity-[0.03] group-hover:opacity-[0.08] transition-all duration-700 group-hover:scale-125 group-hover:-rotate-6">
|
||||||
<MessageSquare className="h-80 w-80 text-violet-500" />
|
<MessageSquare className="h-80 w-80 text-zinc-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative space-y-8">
|
<div className="relative space-y-8">
|
||||||
<div className="w-20 h-20 rounded-[1.5rem] bg-background flex items-center justify-center text-4xl shadow-2xl border border-border/50 group-hover:scale-110 transition-transform duration-500">
|
<div className="w-20 h-20 rounded-[1.5rem] bg-background flex items-center justify-center text-4xl shadow-2xl border border-border/50 group-hover:scale-110 transition-transform duration-500">
|
||||||
@@ -116,12 +116,12 @@ export default function AITestPage() {
|
|||||||
<p className="text-lg text-muted-foreground font-bold opacity-80 leading-relaxed">{t('admin.aiTest.chatTestDescription')}</p>
|
<p className="text-lg text-muted-foreground font-bold opacity-80 leading-relaxed">{t('admin.aiTest.chatTestDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<span className="px-4 py-2 bg-violet-500/10 rounded-xl text-violet-600 text-[10px] font-black uppercase tracking-widest">Conversational</span>
|
<span className="px-4 py-2 bg-zinc-500/10 rounded-xl text-zinc-600 text-[10px] font-black uppercase tracking-widest">Conversational</span>
|
||||||
<span className="px-4 py-2 bg-violet-500/10 rounded-xl text-violet-600 text-[10px] font-black uppercase tracking-widest">Streaming</span>
|
<span className="px-4 py-2 bg-zinc-500/10 rounded-xl text-zinc-600 text-[10px] font-black uppercase tracking-widest">Streaming</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-violet-500/[0.01]">
|
<div className="xl:w-2/3 p-12 md:p-16 bg-gradient-to-l from-transparent to-zinc-500/[0.01]">
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<AI_TESTER type="chat" />
|
<AI_TESTER type="chat" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,21 @@
|
|||||||
import { AdminHeader } from '@/components/admin-header'
|
import { AdminSidebar } from '@/components/admin-sidebar'
|
||||||
import { AdminNav } from '@/components/admin-nav'
|
|
||||||
|
|
||||||
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
|
// Auth is enforced solely by middleware (auth.config.ts → authorized callback).
|
||||||
// All cross-group navigation (admin ↔ main) uses <a> tags (full page reload)
|
// Navigation admin ↔ app en <a> (rechargement complet) pour éviter React Error #310
|
||||||
// to avoid React Error #310 caused by Next.js 16.x route-group transition bug.
|
// sur les transitions entre route groups (Next.js 16 / React #33580).
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex flex-col min-h-screen">
|
<div className="flex h-screen overflow-hidden bg-[#E5E2D9] dark:bg-background">
|
||||||
<AdminHeader />
|
<AdminSidebar />
|
||||||
|
<main className="memento-paper-texture flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth">
|
||||||
{/* Horizontal Tab Navigation */}
|
<div className="mx-auto w-full max-w-6xl space-y-8 px-4 py-6 sm:px-6 sm:py-8 lg:px-10">
|
||||||
<div className="flex items-center gap-1 px-8 bg-background border-b border-border shrink-0">
|
|
||||||
<AdminNav />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Page Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<div className="max-w-5xl mx-auto px-8 py-8 space-y-8">
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,33 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { TestTube, ExternalLink, RefreshCw, Shield, Brain, Mail, Wrench } from 'lucide-react'
|
import { TestTube, ExternalLink, RefreshCw, Shield, Brain, Mail, Wrench } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
type AIProvider = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio'
|
type AIProvider =
|
||||||
|
| 'ollama'
|
||||||
|
| 'openai'
|
||||||
|
| 'anthropic'
|
||||||
|
| 'anthropic_custom'
|
||||||
|
| 'custom'
|
||||||
|
| 'deepseek'
|
||||||
|
| 'openrouter'
|
||||||
|
| 'mistral'
|
||||||
|
| 'zai'
|
||||||
|
| 'lmstudio'
|
||||||
|
|
||||||
|
/** Providers that cannot be used for embeddings in Memento (no embedding API wired). */
|
||||||
|
const PROVIDERS_WITHOUT_EMBEDDINGS: AIProvider[] = ['anthropic', 'anthropic_custom']
|
||||||
|
|
||||||
// Provider config metadata
|
// Provider config metadata
|
||||||
const PROVIDER_META: Record<AIProvider, { apiKeyLabel: string; baseUrlLabel: string; hasApiKey: boolean; hasBaseUrl: boolean; isLocal: boolean }> = {
|
const PROVIDER_META: Record<AIProvider, { apiKeyLabel: string; baseUrlLabel: string; hasApiKey: boolean; hasBaseUrl: boolean; isLocal: boolean }> = {
|
||||||
ollama: { apiKeyLabel: '', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: false, hasBaseUrl: true, isLocal: true },
|
ollama: { apiKeyLabel: '', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: false, hasBaseUrl: true, isLocal: true },
|
||||||
openai: { apiKeyLabel: 'OPENAI_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
openai: { apiKeyLabel: 'OPENAI_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
||||||
|
anthropic: { apiKeyLabel: 'ANTHROPIC_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
||||||
|
anthropic_custom: {
|
||||||
|
apiKeyLabel: 'ANTHROPIC_CUSTOM_API_KEY',
|
||||||
|
baseUrlLabel: 'admin.ai.baseUrl',
|
||||||
|
hasApiKey: true,
|
||||||
|
hasBaseUrl: true,
|
||||||
|
isLocal: false,
|
||||||
|
},
|
||||||
deepseek: { apiKeyLabel: 'DEEPSEEK_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
deepseek: { apiKeyLabel: 'DEEPSEEK_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
||||||
openrouter:{ apiKeyLabel: 'OPENROUTER_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
openrouter:{ apiKeyLabel: 'OPENROUTER_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
||||||
mistral: { apiKeyLabel: 'MISTRAL_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
mistral: { apiKeyLabel: 'MISTRAL_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
||||||
@@ -30,6 +51,8 @@ const PROVIDER_META: Record<AIProvider, { apiKeyLabel: string; baseUrlLabel: str
|
|||||||
const API_KEY_CONFIG: Record<AIProvider, string> = {
|
const API_KEY_CONFIG: Record<AIProvider, string> = {
|
||||||
ollama: '',
|
ollama: '',
|
||||||
openai: 'OPENAI_API_KEY',
|
openai: 'OPENAI_API_KEY',
|
||||||
|
anthropic: 'ANTHROPIC_API_KEY',
|
||||||
|
anthropic_custom: 'ANTHROPIC_CUSTOM_API_KEY',
|
||||||
deepseek: 'DEEPSEEK_API_KEY',
|
deepseek: 'DEEPSEEK_API_KEY',
|
||||||
openrouter: 'OPENROUTER_API_KEY',
|
openrouter: 'OPENROUTER_API_KEY',
|
||||||
mistral: 'MISTRAL_API_KEY',
|
mistral: 'MISTRAL_API_KEY',
|
||||||
@@ -41,6 +64,8 @@ const API_KEY_CONFIG: Record<AIProvider, string> = {
|
|||||||
const BASE_URL_CONFIG: Record<AIProvider, string> = {
|
const BASE_URL_CONFIG: Record<AIProvider, string> = {
|
||||||
ollama: 'OLLAMA_BASE_URL',
|
ollama: 'OLLAMA_BASE_URL',
|
||||||
openai: '',
|
openai: '',
|
||||||
|
anthropic: '',
|
||||||
|
anthropic_custom: 'ANTHROPIC_CUSTOM_BASE_URL',
|
||||||
deepseek: '',
|
deepseek: '',
|
||||||
openrouter: '',
|
openrouter: '',
|
||||||
mistral: '',
|
mistral: '',
|
||||||
@@ -52,6 +77,8 @@ const BASE_URL_CONFIG: Record<AIProvider, string> = {
|
|||||||
const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
|
const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
|
||||||
ollama: 'http://localhost:11434',
|
ollama: 'http://localhost:11434',
|
||||||
openai: '',
|
openai: '',
|
||||||
|
anthropic: '',
|
||||||
|
anthropic_custom: '',
|
||||||
deepseek: 'https://api.deepseek.com/v1',
|
deepseek: 'https://api.deepseek.com/v1',
|
||||||
openrouter: 'https://openrouter.ai/api/v1',
|
openrouter: 'https://openrouter.ai/api/v1',
|
||||||
mistral: 'https://api.mistral.ai/v1',
|
mistral: 'https://api.mistral.ai/v1',
|
||||||
@@ -63,6 +90,24 @@ const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
|
|||||||
// Suggested models per provider (shown as hints in Combobox - user can always type a custom name)
|
// Suggested models per provider (shown as hints in Combobox - user can always type a custom name)
|
||||||
const SUGGESTED_MODELS: Record<string, string[]> = {
|
const SUGGESTED_MODELS: Record<string, string[]> = {
|
||||||
openai: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4o', 'gpt-4o-mini', 'o3-mini', 'o4-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
|
openai: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4o', 'gpt-4o-mini', 'o3-mini', 'o4-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
|
||||||
|
anthropic: [
|
||||||
|
'claude-sonnet-4-20250514',
|
||||||
|
'claude-sonnet-4-5',
|
||||||
|
'claude-opus-4-20250514',
|
||||||
|
'claude-opus-4-5',
|
||||||
|
'claude-haiku-4-5',
|
||||||
|
'claude-3-haiku-20240307',
|
||||||
|
],
|
||||||
|
anthropic_custom: [
|
||||||
|
'MiniMax-M2.7',
|
||||||
|
'MiniMax-M2.7-highspeed',
|
||||||
|
'MiniMax-M2.5',
|
||||||
|
'MiniMax-M2.5-highspeed',
|
||||||
|
'MiniMax-M2.1',
|
||||||
|
'MiniMax-M2.1-highspeed',
|
||||||
|
'MiniMax-M2',
|
||||||
|
'claude-sonnet-4-20250514',
|
||||||
|
],
|
||||||
openrouter: ['openai/gpt-4o-mini', 'openai/gpt-4.1-mini', 'anthropic/claude-sonnet-4', 'google/gemini-2.5-flash-preview', 'google/gemma-4-26b-a4b-it', 'meta-llama/llama-4-maverick', 'deepseek/deepseek-chat-v3-0324'],
|
openrouter: ['openai/gpt-4o-mini', 'openai/gpt-4.1-mini', 'anthropic/claude-sonnet-4', 'google/gemini-2.5-flash-preview', 'google/gemma-4-26b-a4b-it', 'meta-llama/llama-4-maverick', 'deepseek/deepseek-chat-v3-0324'],
|
||||||
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
|
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
|
||||||
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest', 'codestral-latest', 'mistral-embed'],
|
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest', 'codestral-latest', 'mistral-embed'],
|
||||||
@@ -100,7 +145,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
|
|
||||||
// AI Provider state - separated for tags, embeddings, and chat
|
// AI Provider state - separated for tags, embeddings, and chat
|
||||||
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||||
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>(() => {
|
||||||
|
const v = (config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama'
|
||||||
|
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
|
||||||
|
})
|
||||||
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
|
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
|
||||||
|
|
||||||
// Selected Models State
|
// Selected Models State
|
||||||
@@ -170,7 +218,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
await fetchModels('tags', 'ollama', config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
await fetchModels('tags', 'ollama', config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||||
} else if (tagsProvider === 'lmstudio') {
|
} else if (tagsProvider === 'lmstudio') {
|
||||||
await fetchModels('tags', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
await fetchModels('tags', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
||||||
} else if (PROVIDER_META[tagsProvider]?.hasApiKey) {
|
} else if (PROVIDER_META[tagsProvider]?.hasApiKey && tagsProvider !== 'anthropic_custom') {
|
||||||
const url = DEFAULT_BASE_URLS[tagsProvider]
|
const url = DEFAULT_BASE_URLS[tagsProvider]
|
||||||
const key = config[API_KEY_CONFIG[tagsProvider]] || ''
|
const key = config[API_KEY_CONFIG[tagsProvider]] || ''
|
||||||
if (url && key) await fetchModels('tags', tagsProvider, url, key)
|
if (url && key) await fetchModels('tags', tagsProvider, url, key)
|
||||||
@@ -180,7 +228,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
await fetchModels('embeddings', 'ollama', config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
await fetchModels('embeddings', 'ollama', config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||||
} else if (embeddingsProvider === 'lmstudio') {
|
} else if (embeddingsProvider === 'lmstudio') {
|
||||||
await fetchModels('embeddings', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
await fetchModels('embeddings', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
||||||
} else if (PROVIDER_META[embeddingsProvider]?.hasApiKey) {
|
} else if (PROVIDER_META[embeddingsProvider]?.hasApiKey && embeddingsProvider !== 'anthropic_custom') {
|
||||||
const url = DEFAULT_BASE_URLS[embeddingsProvider]
|
const url = DEFAULT_BASE_URLS[embeddingsProvider]
|
||||||
const key = config[API_KEY_CONFIG[embeddingsProvider]] || ''
|
const key = config[API_KEY_CONFIG[embeddingsProvider]] || ''
|
||||||
if (url && key) await fetchModels('embeddings', embeddingsProvider, url, key)
|
if (url && key) await fetchModels('embeddings', embeddingsProvider, url, key)
|
||||||
@@ -190,7 +238,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
await fetchModels('chat', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
await fetchModels('chat', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||||
} else if (chatProvider === 'lmstudio') {
|
} else if (chatProvider === 'lmstudio') {
|
||||||
await fetchModels('chat', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
await fetchModels('chat', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
||||||
} else if (PROVIDER_META[chatProvider]?.hasApiKey) {
|
} else if (PROVIDER_META[chatProvider]?.hasApiKey && chatProvider !== 'anthropic_custom') {
|
||||||
const url = DEFAULT_BASE_URLS[chatProvider]
|
const url = DEFAULT_BASE_URLS[chatProvider]
|
||||||
const key = config[API_KEY_CONFIG[chatProvider]] || ''
|
const key = config[API_KEY_CONFIG[chatProvider]] || ''
|
||||||
if (url && key) await fetchModels('chat', chatProvider, url, key)
|
if (url && key) await fetchModels('chat', chatProvider, url, key)
|
||||||
@@ -459,13 +507,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
? (config.LMSTUDIO_BASE_URL || DEFAULT_BASE_URLS.lmstudio)
|
? (config.LMSTUDIO_BASE_URL || DEFAULT_BASE_URLS.lmstudio)
|
||||||
: (config[BASE_URL_CONFIG[provider]] || DEFAULT_BASE_URLS[provider] || '')
|
: (config[BASE_URL_CONFIG[provider]] || DEFAULT_BASE_URLS[provider] || '')
|
||||||
}
|
}
|
||||||
placeholder={DEFAULT_BASE_URLS[provider] || t('admin.ai.baseUrl')}
|
placeholder={
|
||||||
|
provider === 'anthropic_custom'
|
||||||
|
? 'https://api.minimax.io/anthropic'
|
||||||
|
: DEFAULT_BASE_URLS[provider] || t('admin.ai.baseUrl')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (provider === 'anthropic_custom') {
|
||||||
|
toast.info(t('admin.ai.anthropicCustomNoModelList'))
|
||||||
|
return
|
||||||
|
}
|
||||||
const urlInput = document.getElementById(`BASE_URL_${provider}_${purpose}`) as HTMLInputElement
|
const urlInput = document.getElementById(`BASE_URL_${provider}_${purpose}`) as HTMLInputElement
|
||||||
const keyInput = meta.hasApiKey
|
const keyInput = meta.hasApiKey
|
||||||
? document.getElementById(`API_KEY_${provider}_${purpose}`) as HTMLInputElement
|
? document.getElementById(`API_KEY_${provider}_${purpose}`) as HTMLInputElement
|
||||||
@@ -474,7 +530,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
const key = keyInput?.value || (meta.hasApiKey ? config[API_KEY_CONFIG[provider]] : undefined)
|
const key = keyInput?.value || (meta.hasApiKey ? config[API_KEY_CONFIG[provider]] : undefined)
|
||||||
if (url) fetchModels(purpose, provider, url, key)
|
if (url) fetchModels(purpose, provider, url, key)
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading || provider === 'anthropic_custom'}
|
||||||
title={t('admin.ai.refreshModels')}
|
title={t('admin.ai.refreshModels')}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
@@ -500,7 +556,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
const key = keyInput?.value || config[API_KEY_CONFIG[provider]] || ''
|
const key = keyInput?.value || config[API_KEY_CONFIG[provider]] || ''
|
||||||
if (url && key) fetchModels(purpose, provider, url, key)
|
if (url && key) fetchModels(purpose, provider, url, key)
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading || provider === 'anthropic'}
|
||||||
title={t('admin.ai.refreshModels')}
|
title={t('admin.ai.refreshModels')}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
@@ -525,9 +581,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
? t('admin.ai.fetchingModels')
|
? t('admin.ai.fetchingModels')
|
||||||
: dynamicModels[purpose].length > 0
|
: dynamicModels[purpose].length > 0
|
||||||
? t('admin.ai.modelsAvailable', { count: dynamicModels[purpose].length })
|
? t('admin.ai.modelsAvailable', { count: dynamicModels[purpose].length })
|
||||||
: provider === 'ollama' || provider === 'lmstudio'
|
: provider === 'anthropic'
|
||||||
? t('admin.ai.selectOllamaModel')
|
? t('admin.ai.anthropicModelHint')
|
||||||
: t('admin.ai.enterUrlToLoad')}
|
: provider === 'anthropic_custom'
|
||||||
|
? t('admin.ai.anthropicCustomModelHint')
|
||||||
|
: provider === 'ollama' || provider === 'lmstudio'
|
||||||
|
? t('admin.ai.selectOllamaModel')
|
||||||
|
: t('admin.ai.enterUrlToLoad')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -538,6 +598,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
const providerOptions = [
|
const providerOptions = [
|
||||||
{ value: 'ollama', label: t('admin.ai.providerOllamaOption') },
|
{ value: 'ollama', label: t('admin.ai.providerOllamaOption') },
|
||||||
{ value: 'openai', label: t('admin.ai.providerOpenAIOption') },
|
{ value: 'openai', label: t('admin.ai.providerOpenAIOption') },
|
||||||
|
{ value: 'anthropic', label: t('admin.ai.providerAnthropicOption') },
|
||||||
|
{ value: 'anthropic_custom', label: t('admin.ai.providerAnthropicCustomOption') },
|
||||||
{ value: 'deepseek', label: t('admin.ai.providerDeepSeekOption') },
|
{ value: 'deepseek', label: t('admin.ai.providerDeepSeekOption') },
|
||||||
{ value: 'openrouter', label: t('admin.ai.providerOpenRouterOption') },
|
{ value: 'openrouter', label: t('admin.ai.providerOpenRouterOption') },
|
||||||
{ value: 'mistral', label: t('admin.ai.providerMistralOption') },
|
{ value: 'mistral', label: t('admin.ai.providerMistralOption') },
|
||||||
@@ -546,6 +608,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
{ value: 'custom', label: t('admin.ai.providerCustomOption') },
|
{ value: 'custom', label: t('admin.ai.providerCustomOption') },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const embeddingsProviderOptions = providerOptions.filter(
|
||||||
|
(opt) => !PROVIDERS_WITHOUT_EMBEDDINGS.includes(opt.value as AIProvider)
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="columns-1 lg:columns-2 gap-6">
|
<div className="columns-1 lg:columns-2 gap-6">
|
||||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||||
@@ -657,7 +723,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
}}
|
}}
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
{providerOptions.map(opt => (
|
{embeddingsProviderOptions.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -670,7 +736,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
|||||||
{/* Chat Provider */}
|
{/* Chat Provider */}
|
||||||
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'chat' ? 'block' : 'hidden'}`}>
|
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
<span className="text-blue-600">💬</span> {t('admin.ai.chatProvider')}
|
<span className="text-zinc-600">💬</span> {t('admin.ai.chatProvider')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.ai.chatDescription')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.ai.chatDescription')}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -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-memento-desk">
|
||||||
<HeaderWrapper user={session?.user} />
|
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
|
||||||
|
<Sidebar user={session?.user} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{/* Main Layout */}
|
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper">
|
||||||
<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
|
||||||
@@ -23,6 +25,7 @@ export default async function HomePage() {
|
|||||||
notesViewMode,
|
notesViewMode,
|
||||||
noteHistory: settings?.noteHistory === true,
|
noteHistory: settings?.noteHistory === true,
|
||||||
noteHistoryMode: (settings?.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
noteHistoryMode: (settings?.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
||||||
|
aiAssistantEnabled: settings?.paragraphRefactor !== false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { getNotesWithReminders } from '@/app/actions/notes'
|
|
||||||
import { RemindersPage } from '@/components/reminders-page'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export default async function RemindersRoute() {
|
|
||||||
const notes = await getNotesWithReminders()
|
|
||||||
return <RemindersPage notes={notes} />
|
|
||||||
}
|
|
||||||
@@ -11,10 +11,9 @@ export default function AboutSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('about.title')}</h1>
|
{t('about.description')}
|
||||||
<p className="text-muted-foreground mt-1">{t('about.description')}</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* App info */}
|
{/* App info */}
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ export function AISettingsHeader() {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('aiSettings.title')}</h1>
|
{t('aiSettings.description')}
|
||||||
<p className="text-muted-foreground mt-1">{t('aiSettings.description')}</p>
|
</p>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -71,12 +63,18 @@ export function AppearanceSettingsClient({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleFontFamilyChange = async (value: string) => {
|
const handleFontFamilyChange = async (value: string) => {
|
||||||
const font = value === 'system' ? 'system' : 'inter'
|
const font = value === 'system' ? 'system'
|
||||||
|
: value === 'playfair' ? 'playfair'
|
||||||
|
: value === 'jetbrains' ? 'jetbrains'
|
||||||
|
: 'inter'
|
||||||
setFontFamily(font)
|
setFontFamily(font)
|
||||||
localStorage.setItem('font-family', font)
|
localStorage.setItem('font-family', font)
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
font === 'system' ? root.classList.add('font-system') : root.classList.remove('font-system')
|
root.classList.remove('font-system', 'font-playfair', 'font-jetbrains')
|
||||||
await updateAISettings({ fontFamily: font })
|
if (font === 'system') root.classList.add('font-system')
|
||||||
|
if (font === 'playfair') root.classList.add('font-playfair')
|
||||||
|
if (font === 'jetbrains') root.classList.add('font-jetbrains')
|
||||||
|
await updateAISettings({ fontFamily: font as 'inter' | 'playfair' | 'jetbrains' | 'system' })
|
||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +85,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,23 +111,62 @@ 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>
|
{/* Section label — architectural style */}
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('appearance.title')}</h1>
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<p className="text-muted-foreground mt-1">{t('appearance.description')}</p>
|
{t('appearance.description') || "Personnalisez l'interface"}
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<SelectCard
|
<SelectCard
|
||||||
@@ -135,19 +174,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,10 +195,12 @@ 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 (défaut)' },
|
||||||
|
{ value: 'playfair', label: 'Playfair Display' },
|
||||||
|
{ value: 'jetbrains', label: 'JetBrains Mono' },
|
||||||
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
|
{ value: 'system', label: t('appearance.fontSystem') || 'Système' },
|
||||||
]}
|
]}
|
||||||
onChange={handleFontFamilyChange}
|
onChange={handleFontFamilyChange}
|
||||||
@@ -184,6 +213,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'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -118,17 +118,16 @@ export default function DataSettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8 p-6">
|
<div className="space-y-8">
|
||||||
<div className="space-y-1">
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">{t('dataManagement.title')}</h1>
|
{t('dataManagement.toolsDescription')}
|
||||||
<p className="text-muted-foreground">{t('dataManagement.toolsDescription')}</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Export card */}
|
{/* Export card */}
|
||||||
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
<div className="bg-card rounded-xl border border-border p-6 shadow-sm flex flex-col justify-between transition-all hover:shadow-md">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="w-12 h-12 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-600 shrink-0">
|
<div className="w-12 h-12 rounded-full bg-zinc-500/10 flex items-center justify-center text-zinc-600 shrink-0">
|
||||||
<Download className="h-6 w-6" />
|
<Download className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface GeneralSettingsClientProps {
|
|||||||
preferredLanguage: string
|
preferredLanguage: string
|
||||||
emailNotifications: boolean
|
emailNotifications: boolean
|
||||||
desktopNotifications: boolean
|
desktopNotifications: boolean
|
||||||
|
autoSave: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,15 +22,18 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
||||||
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
||||||
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
||||||
|
const [autoSave, setAutoSave] = useState(initialSettings.autoSave ?? true)
|
||||||
|
|
||||||
const handleLanguageChange = async (value: string) => {
|
const handleLanguageChange = async (value: string) => {
|
||||||
setLanguage(value)
|
setLanguage(value)
|
||||||
await updateAISettings({ preferredLanguage: value as any })
|
await updateAISettings({ preferredLanguage: value as any })
|
||||||
if (value === 'auto') {
|
if (value === 'auto') {
|
||||||
localStorage.removeItem('user-language')
|
localStorage.removeItem('user-language')
|
||||||
|
document.cookie = 'user-language=;path=/;max-age=0'
|
||||||
toast.success(t('settings.languageAuto') || 'Language set to Auto')
|
toast.success(t('settings.languageAuto') || 'Language set to Auto')
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('user-language', value)
|
localStorage.setItem('user-language', value)
|
||||||
|
document.cookie = `user-language=${value};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`
|
||||||
setContextLanguage(value as any)
|
setContextLanguage(value as any)
|
||||||
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
|
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
|
||||||
}
|
}
|
||||||
@@ -47,14 +51,18 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
await updateAISettings({ desktopNotifications: enabled })
|
await updateAISettings({ desktopNotifications: enabled })
|
||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAutoSaveChange = async (enabled: boolean) => {
|
||||||
|
setAutoSave(enabled)
|
||||||
|
await updateAISettings({ autoSave: enabled })
|
||||||
|
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Page title */}
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<div>
|
{t('generalSettings.description')}
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('generalSettings.title')}</h1>
|
</p>
|
||||||
<p className="text-muted-foreground mt-1">{t('generalSettings.description')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 2-column card grid */}
|
{/* 2-column card grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
@@ -142,6 +150,22 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
|||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${desktopNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${desktopNotifications ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border pt-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">{t('settings.autoSave') || 'Auto-Save'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('settings.autoSaveDesc') || 'Sauvegarder automatiquement les modifications'}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={autoSave}
|
||||||
|
onClick={() => handleAutoSaveChange(!autoSave)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 ${autoSave ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSave ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ export default async function GeneralSettingsPage() {
|
|||||||
redirect('/api/auth/signin')
|
redirect('/api/auth/signin')
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getAISettings()
|
const {
|
||||||
|
preferredLanguage,
|
||||||
|
emailNotifications,
|
||||||
|
desktopNotifications,
|
||||||
|
autoSave,
|
||||||
|
} = await getAISettings()
|
||||||
|
|
||||||
return <GeneralSettingsClient initialSettings={settings} />
|
return (
|
||||||
|
<GeneralSettingsClient
|
||||||
|
initialSettings={{ preferredLanguage, emailNotifications, desktopNotifications, autoSave }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,30 @@ export default function SettingsLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full bg-[#F2F0E9]">
|
||||||
{/* Horizontal Tab Navigation */}
|
{/* Architectural header — matches Agents page */}
|
||||||
<header className="flex items-center gap-1 px-8 bg-background border-b border-border shrink-0">
|
<header className="flex flex-col px-12 pt-10 pb-0 border-b border-border/40 shrink-0">
|
||||||
<SettingsNav />
|
<div className="flex items-end justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight">
|
||||||
|
Paramètres
|
||||||
|
</h1>
|
||||||
|
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-2">
|
||||||
|
Configuration & Préférences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Tab nav flush to the border-bottom of header */}
|
||||||
|
<SettingsNav className="-mb-px" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Page Content */}
|
{/* Page Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-5xl mx-auto px-8 py-8 space-y-8">
|
<div className="max-w-5xl mx-auto px-12 py-10 space-y-8">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ export default async function McpSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">Paramètres MCP</h1>
|
Gérez vos clés API et serveurs MCP connectés.
|
||||||
<p className="text-muted-foreground mt-1">Gérez vos clés API et serveurs MCP connectés.</p>
|
</p>
|
||||||
</div>
|
|
||||||
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t('profile.title')}</h1>
|
{t('profile.description')}
|
||||||
<p className="text-muted-foreground mt-1">{t('profile.description')}</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Profile info card */}
|
{/* Profile info card */}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export type UserAISettingsData = {
|
|||||||
autoLabeling?: boolean
|
autoLabeling?: boolean
|
||||||
noteHistory?: boolean
|
noteHistory?: boolean
|
||||||
noteHistoryMode?: 'manual' | 'auto'
|
noteHistoryMode?: 'manual' | 'auto'
|
||||||
fontFamily?: 'inter' | 'system'
|
fontFamily?: 'inter' | 'playfair' | 'jetbrains' | 'system'
|
||||||
|
autoSave?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
|
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
|
||||||
@@ -47,6 +48,7 @@ const USER_AI_SETTINGS_PRISMA_KEYS = [
|
|||||||
'noteHistory',
|
'noteHistory',
|
||||||
'noteHistoryMode',
|
'noteHistoryMode',
|
||||||
'fontFamily',
|
'fontFamily',
|
||||||
|
'autoSave',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
|
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
|
||||||
@@ -59,13 +61,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
|
||||||
}
|
}
|
||||||
@@ -160,6 +160,7 @@ const getCachedAISettings = unstable_cache(
|
|||||||
noteHistory: false,
|
noteHistory: false,
|
||||||
noteHistoryMode: 'manual' as const,
|
noteHistoryMode: 'manual' as const,
|
||||||
fontFamily: 'inter' as const,
|
fontFamily: 'inter' as const,
|
||||||
|
autoSave: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,9 +168,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,
|
||||||
@@ -191,7 +194,8 @@ const getCachedAISettings = unstable_cache(
|
|||||||
autoLabeling: settings.autoLabeling ?? true,
|
autoLabeling: settings.autoLabeling ?? true,
|
||||||
noteHistory: settings.noteHistory ?? false,
|
noteHistory: settings.noteHistory ?? false,
|
||||||
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
noteHistoryMode: (settings.noteHistoryMode ?? 'manual') as 'manual' | 'auto',
|
||||||
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'system',
|
fontFamily: (settings.fontFamily || 'inter') as 'inter' | 'playfair' | 'jetbrains' | 'system',
|
||||||
|
autoSave: settings.autoSave ?? true,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting AI settings:', error)
|
console.error('Error getting AI settings:', error)
|
||||||
@@ -217,6 +221,7 @@ const getCachedAISettings = unstable_cache(
|
|||||||
noteHistory: false,
|
noteHistory: false,
|
||||||
noteHistoryMode: 'manual' as const,
|
noteHistoryMode: 'manual' as const,
|
||||||
fontFamily: 'inter' as const,
|
fontFamily: 'inter' as const,
|
||||||
|
autoSave: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
100
memento-note/app/actions/note-illustration.ts
Normal file
100
memento-note/app/actions/note-illustration.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import DOMPurify from 'isomorphic-dompurify'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { getAIProvider } from '@/lib/ai/factory'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
|
function extractSvgSnippet(raw: string): string | null {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
const fenced = trimmed.match(/```(?:svg)?\s*([\s\S]*?)```/i)
|
||||||
|
const candidate = (fenced ? fenced[1] : trimmed).trim()
|
||||||
|
const start = candidate.indexOf('<svg')
|
||||||
|
const end = candidate.lastIndexOf('</svg>')
|
||||||
|
if (start === -1 || end === -1 || end <= start) return null
|
||||||
|
return candidate.slice(start, end + 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSvgMarkup(svg: string): string {
|
||||||
|
return DOMPurify.sanitize(svg, {
|
||||||
|
USE_PROFILES: { svg: true, svgFilters: true },
|
||||||
|
ADD_TAGS: ['use'],
|
||||||
|
ADD_ATTR: ['viewBox', 'xmlns', 'preserveAspectRatio'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère une miniature SVG abstraite pour le flux éditorial (via modèle chat configuré).
|
||||||
|
* Respecte les préférences utilisateur (assistant IA activé) et nettoie le SVG.
|
||||||
|
*/
|
||||||
|
export async function generateNoteIllustrationSvg(noteId: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) return { ok: false, error: 'Non autorisé' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await getAISettings(session.user.id)
|
||||||
|
if (settings.paragraphRefactor === false) {
|
||||||
|
return { ok: false, error: 'Assistant IA désactivé dans vos paramètres.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = await prisma.note.findFirst({
|
||||||
|
where: { id: noteId, userId: session.user.id },
|
||||||
|
select: { id: true, title: true, content: true },
|
||||||
|
})
|
||||||
|
if (!note) return { ok: false, error: 'Note introuvable' }
|
||||||
|
|
||||||
|
const plainTitle = (note.title || '').slice(0, 200)
|
||||||
|
const plainBody = note.content
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 1200)
|
||||||
|
|
||||||
|
if (!plainBody && !plainTitle) {
|
||||||
|
return { ok: false, error: 'Ajoutez du contenu avant de générer une illustration.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const provider = getAIProvider(config)
|
||||||
|
|
||||||
|
const prompt = `Tu es un designer minimaliste. Produis UN SEUL document SVG valide pour une vignette de carte note.
|
||||||
|
Contraintes strictes:
|
||||||
|
- viewBox="0 0 224 168" (rapport 4:3), pas de width/height fixes en px sur la racine ou width="100%" height="100%"
|
||||||
|
- Style architectural / papier, 2–4 formes géométriques ou lignes, palette sobre (noir/gris/une couleur douce), pas de texte lisible
|
||||||
|
- AUCUN script, AUCUNE balise foreignObject, AUCUN lien externe, AUCUN attribut on*
|
||||||
|
- Réponds UNIQUEMENT avec le fragment SVG (commence par <svg ...> et finit par </svg>), sans markdown ni commentaire.
|
||||||
|
|
||||||
|
Thème à suggérer visuellement (abstrait, pas littéral):
|
||||||
|
Titre: ${plainTitle || '(sans titre)'}
|
||||||
|
Extrait: ${plainBody.slice(0, 400)}`
|
||||||
|
|
||||||
|
const raw = await provider.generateText(prompt)
|
||||||
|
const extracted = extractSvgSnippet(raw)
|
||||||
|
if (!extracted) {
|
||||||
|
return { ok: false, error: 'Le modèle n’a pas renvoyé un SVG valide. Réessayez.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const safe = sanitizeSvgMarkup(extracted)
|
||||||
|
if (!safe.includes('<svg')) {
|
||||||
|
return { ok: false, error: 'SVG rejeté après sécurisation.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.note.update({
|
||||||
|
where: { id: noteId, userId: session.user.id },
|
||||||
|
data: {
|
||||||
|
illustrationSvg: safe,
|
||||||
|
lastAiAnalysis: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/')
|
||||||
|
return { ok: true }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[note-illustration]', e)
|
||||||
|
const msg = e instanceof Error ? e.message : 'Erreur inconnue'
|
||||||
|
return { ok: false, error: msg.includes('required') ? 'Configurez un fournisseur IA (admin ou paramètres système).' : msg }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ const NOTE_LIST_SELECT = {
|
|||||||
checkItems: true,
|
checkItems: true,
|
||||||
labels: true,
|
labels: true,
|
||||||
images: true,
|
images: true,
|
||||||
|
illustrationSvg: true,
|
||||||
links: true,
|
links: true,
|
||||||
reminder: true,
|
reminder: true,
|
||||||
isReminderDone: true,
|
isReminderDone: true,
|
||||||
@@ -213,7 +214,7 @@ async function syncNoteLabels(noteId: string, labelNames: string[], notebookId:
|
|||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase()))
|
parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase()))
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Delete labels not in use
|
// Delete labels not in use
|
||||||
@@ -371,16 +372,14 @@ export async function getNoteHistory(noteId: string, limit = 30) {
|
|||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) return []
|
if (!session?.user?.id) return []
|
||||||
|
|
||||||
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
|
|
||||||
if (!enabled) return []
|
|
||||||
|
|
||||||
const clampedLimit = Math.min(Math.max(limit, 1), 100)
|
const clampedLimit = Math.min(Math.max(limit, 1), 100)
|
||||||
|
|
||||||
const note = await prisma.note.findFirst({
|
const note = await prisma.note.findFirst({
|
||||||
where: { id: noteId, userId: session.user.id },
|
where: { id: noteId, userId: session.user.id },
|
||||||
select: { id: true },
|
select: { id: true, historyEnabled: true },
|
||||||
})
|
})
|
||||||
if (!note) return []
|
// History not found or not enabled on this note
|
||||||
|
if (!note || !note.historyEnabled) return []
|
||||||
|
|
||||||
const entries = await prisma.noteHistory.findMany({
|
const entries = await prisma.noteHistory.findMany({
|
||||||
where: { noteId: note.id, userId: session.user.id },
|
where: { noteId: note.id, userId: session.user.id },
|
||||||
@@ -395,13 +394,10 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string)
|
|||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||||
|
|
||||||
const enabled = await isNoteHistoryEnabledForUser(session.user.id)
|
|
||||||
if (!enabled) throw new Error('History is disabled')
|
|
||||||
|
|
||||||
const [note, historyEntry] = await Promise.all([
|
const [note, historyEntry] = await Promise.all([
|
||||||
prisma.note.findFirst({
|
prisma.note.findFirst({
|
||||||
where: { id: noteId, userId: session.user.id },
|
where: { id: noteId, userId: session.user.id },
|
||||||
select: { id: true, notebookId: true },
|
select: { id: true, notebookId: true, historyEnabled: true },
|
||||||
}),
|
}),
|
||||||
prisma.noteHistory.findFirst({
|
prisma.noteHistory.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -412,9 +408,8 @@ export async function restoreNoteVersion(noteId: string, historyEntryId: string)
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!note || !historyEntry) {
|
if (!note || !note.historyEnabled) throw new Error('History is disabled for this note')
|
||||||
throw new Error('History entry not found')
|
if (!historyEntry) throw new Error('History entry not found')
|
||||||
}
|
|
||||||
|
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
@@ -685,91 +680,91 @@ export async function createNote(data: {
|
|||||||
const notebookId = data.notebookId
|
const notebookId = data.notebookId
|
||||||
const hasUserLabels = data.labels && data.labels.length > 0
|
const hasUserLabels = data.labels && data.labels.length > 0
|
||||||
|
|
||||||
// Use setImmediate-like pattern to not block the response
|
// Use setImmediate-like pattern to not block the response
|
||||||
;(async () => {
|
; (async () => {
|
||||||
try {
|
|
||||||
// Background task 1: Generate embedding
|
|
||||||
const bgConfig = await getSystemConfig()
|
|
||||||
const provider = getAIProvider(bgConfig)
|
|
||||||
const embedding = await provider.getEmbeddings(content)
|
|
||||||
if (embedding) {
|
|
||||||
await prisma.noteEmbedding.upsert({
|
|
||||||
where: { noteId: noteId },
|
|
||||||
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
|
|
||||||
update: { embedding: JSON.stringify(embedding) }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[BG] Embedding generation failed:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background task 2: Auto-labeling (only if no user labels and has notebook)
|
|
||||||
if (!hasUserLabels && notebookId) {
|
|
||||||
try {
|
try {
|
||||||
const userAISettings = await getAISettings(userId)
|
// Background task 1: Generate embedding
|
||||||
const autoLabelingEnabled = userAISettings.autoLabeling !== false
|
const bgConfig = await getSystemConfig()
|
||||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
|
const provider = getAIProvider(bgConfig)
|
||||||
|
const embedding = await provider.getEmbeddings(content)
|
||||||
|
if (embedding) {
|
||||||
|
await prisma.noteEmbedding.upsert({
|
||||||
|
where: { noteId: noteId },
|
||||||
|
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
|
||||||
|
update: { embedding: JSON.stringify(embedding) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[BG] Embedding generation failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
|
// Background task 2: Auto-labeling (only if no user labels and has notebook)
|
||||||
|
if (!hasUserLabels && notebookId) {
|
||||||
|
try {
|
||||||
|
const userAISettings = await getAISettings(userId)
|
||||||
|
const autoLabelingEnabled = userAISettings.autoLabeling !== false
|
||||||
|
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
|
||||||
|
|
||||||
if (autoLabelingEnabled) {
|
console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
|
||||||
// Detect user's language from their existing notes for localized prompts
|
|
||||||
let userLang = 'en'
|
|
||||||
try {
|
|
||||||
const langResult = await prisma.note.groupBy({
|
|
||||||
by: ['language'],
|
|
||||||
where: { userId, language: { not: null } },
|
|
||||||
_count: true,
|
|
||||||
orderBy: { _count: { language: 'desc' } },
|
|
||||||
take: 1,
|
|
||||||
})
|
|
||||||
if (langResult.length > 0 && langResult[0].language) {
|
|
||||||
userLang = langResult[0].language
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
if (autoLabelingEnabled) {
|
||||||
content,
|
// Detect user's language from their existing notes for localized prompts
|
||||||
notebookId,
|
let userLang = 'en'
|
||||||
userId,
|
try {
|
||||||
userLang
|
const langResult = await prisma.note.groupBy({
|
||||||
)
|
by: ['language'],
|
||||||
|
where: { userId, language: { not: null } },
|
||||||
|
_count: true,
|
||||||
|
orderBy: { _count: { language: 'desc' } },
|
||||||
|
take: 1,
|
||||||
|
})
|
||||||
|
if (langResult.length > 0 && langResult[0].language) {
|
||||||
|
userLang = langResult[0].language
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
|
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||||
|
content,
|
||||||
|
notebookId,
|
||||||
|
userId,
|
||||||
|
userLang
|
||||||
|
)
|
||||||
|
|
||||||
const appliedLabels = suggestions
|
console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
|
||||||
.filter(s => s.confidence >= autoLabelingConfidence)
|
|
||||||
.map(s => s.label)
|
|
||||||
|
|
||||||
if (appliedLabels.length > 0) {
|
const appliedLabels = suggestions
|
||||||
// Merge with existing labels
|
.filter(s => s.confidence >= autoLabelingConfidence)
|
||||||
const existing = await prisma.note.findUnique({
|
.map(s => s.label)
|
||||||
where: { id: noteId },
|
|
||||||
select: { labels: true },
|
if (appliedLabels.length > 0) {
|
||||||
})
|
// Merge with existing labels
|
||||||
let existingNames: string[] = []
|
const existing = await prisma.note.findUnique({
|
||||||
if (existing?.labels) {
|
where: { id: noteId },
|
||||||
try {
|
select: { labels: true },
|
||||||
const parsed = existing.labels as unknown
|
})
|
||||||
existingNames = Array.isArray(parsed)
|
let existingNames: string[] = []
|
||||||
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
|
if (existing?.labels) {
|
||||||
: []
|
try {
|
||||||
} catch { existingNames = [] }
|
const parsed = existing.labels as unknown
|
||||||
}
|
existingNames = Array.isArray(parsed)
|
||||||
const merged = [...new Set([...existingNames, ...appliedLabels])]
|
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
|
||||||
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
|
: []
|
||||||
if (!data.skipRevalidation) {
|
} catch { existingNames = [] }
|
||||||
revalidatePath('/')
|
}
|
||||||
|
const merged = [...new Set([...existingNames, ...appliedLabels])]
|
||||||
|
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
|
||||||
|
if (!data.skipRevalidation) {
|
||||||
|
revalidatePath('/')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BG] Auto-labeling failed:', error)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error('[BG] Auto-labeling failed:', error)
|
console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
|
||||||
}
|
}
|
||||||
} else {
|
})()
|
||||||
console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
return parseNote(note)
|
return parseNote(note)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -789,6 +784,7 @@ export async function updateNote(id: string, data: {
|
|||||||
checkItems?: CheckItem[] | null
|
checkItems?: CheckItem[] | null
|
||||||
labels?: string[] | null
|
labels?: string[] | null
|
||||||
images?: string[] | null
|
images?: string[] | null
|
||||||
|
illustrationSvg?: string | null
|
||||||
links?: any[] | null
|
links?: any[] | null
|
||||||
reminder?: Date | null
|
reminder?: Date | null
|
||||||
isMarkdown?: boolean
|
isMarkdown?: boolean
|
||||||
@@ -823,27 +819,28 @@ export async function updateNote(id: string, data: {
|
|||||||
if (data.content !== undefined) {
|
if (data.content !== undefined) {
|
||||||
const noteId = id
|
const noteId = id
|
||||||
const content = data.content
|
const content = data.content
|
||||||
;(async () => {
|
; (async () => {
|
||||||
try {
|
try {
|
||||||
const provider = getAIProvider(await getSystemConfig());
|
const provider = getAIProvider(await getSystemConfig());
|
||||||
const embedding = await provider.getEmbeddings(content);
|
const embedding = await provider.getEmbeddings(content);
|
||||||
if (embedding) {
|
if (embedding) {
|
||||||
await prisma.noteEmbedding.upsert({
|
await prisma.noteEmbedding.upsert({
|
||||||
where: { noteId: noteId },
|
where: { noteId: noteId },
|
||||||
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
|
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
|
||||||
update: { embedding: JSON.stringify(embedding) }
|
update: { embedding: JSON.stringify(embedding) }
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[BG] Embedding regeneration failed:', e);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
})()
|
||||||
console.error('[BG] Embedding regeneration failed:', e);
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
||||||
// labels handled by syncNoteLabels below
|
// labels handled by syncNoteLabels below
|
||||||
delete updateData.labels
|
delete updateData.labels
|
||||||
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
||||||
|
if ('illustrationSvg' in data) updateData.illustrationSvg = data.illustrationSvg
|
||||||
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
|
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
|
||||||
if ('notebookId' in data) updateData.notebookId = data.notebookId
|
if ('notebookId' in data) updateData.notebookId = data.notebookId
|
||||||
// Explicitly handle size to ensure it propagates
|
// Explicitly handle size to ensure it propagates
|
||||||
@@ -852,16 +849,24 @@ export async function updateNote(id: string, data: {
|
|||||||
// Only update contentUpdatedAt for actual content changes, NOT for property changes
|
// Only update contentUpdatedAt for actual content changes, NOT for property changes
|
||||||
// (size, color, isPinned, isArchived are properties, not content)
|
// (size, color, isPinned, isArchived are properties, not content)
|
||||||
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
|
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
|
||||||
const contentFields = ['title', 'content', 'checkItems', 'images', 'links']
|
const contentFields = ['title', 'content', 'checkItems', 'images', 'links', 'illustrationSvg']
|
||||||
const isContentChange = contentFields.some(field => field in data)
|
const isContentChange = contentFields.some(field => field in data)
|
||||||
if (isContentChange && !options?.skipContentTimestamp) {
|
if (isContentChange && !options?.skipContentTimestamp) {
|
||||||
updateData.contentUpdatedAt = new Date()
|
updateData.contentUpdatedAt = new Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = await prisma.note.update({
|
console.log('[updateNote] Attempting update, id:', id, 'userId:', session.user.id)
|
||||||
where: { id, userId: session.user.id },
|
let note
|
||||||
data: updateData
|
try {
|
||||||
})
|
note = await prisma.note.update({
|
||||||
|
where: { id, userId: session.user.id },
|
||||||
|
data: updateData
|
||||||
|
})
|
||||||
|
console.log('[updateNote] Succeeded, note id:', note?.id)
|
||||||
|
} catch (dbError: any) {
|
||||||
|
console.error('[updateNote] FAILED:', dbError.code, dbError.message)
|
||||||
|
throw dbError
|
||||||
|
}
|
||||||
|
|
||||||
// Sync labels (JSON + labelRelations + Label rows)
|
// Sync labels (JSON + labelRelations + Label rows)
|
||||||
const notebookMoved =
|
const notebookMoved =
|
||||||
@@ -905,9 +910,15 @@ export async function updateNote(id: string, data: {
|
|||||||
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
||||||
const isStructuralChange = structuralFields.some(field => field in data)
|
const isStructuralChange = structuralFields.some(field => field in data)
|
||||||
|
|
||||||
if (isStructuralChange && !options?.skipRevalidation) {
|
console.log('[updateNote] Structural check — data fields:', Object.keys(data), '| isStructural:', isStructuralChange)
|
||||||
revalidatePath('/')
|
|
||||||
|
if (!options?.skipRevalidation) {
|
||||||
|
// Always revalidate note individual page on content changes so UI reflects saved data
|
||||||
revalidatePath(`/note/${id}`)
|
revalidatePath(`/note/${id}`)
|
||||||
|
revalidatePath('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStructuralChange) {
|
||||||
if (data.isArchived !== undefined) {
|
if (data.isArchived !== undefined) {
|
||||||
revalidatePath('/archive')
|
revalidatePath('/archive')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,12 @@ export async function POST(req: NextRequest) {
|
|||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
const { noteId, type, theme, style } = body as {
|
const { noteId, type, theme, style, language } = body as {
|
||||||
noteId: string
|
noteId: string
|
||||||
type: GenerateType
|
type: GenerateType
|
||||||
theme?: string
|
theme?: string
|
||||||
style?: string
|
style?: string
|
||||||
|
language?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
||||||
@@ -50,15 +51,26 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaults = TYPE_DEFAULTS[type]
|
const defaults = TYPE_DEFAULTS[type]
|
||||||
|
const isEn = language === 'English'
|
||||||
|
|
||||||
|
let role = defaults.role
|
||||||
|
if (isEn) {
|
||||||
|
if (type === 'slide-generator') {
|
||||||
|
role = 'Create a professional and visual PowerPoint presentation from the provided note content.'
|
||||||
|
} else {
|
||||||
|
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const agentName = type === 'slide-generator'
|
const agentName = type === 'slide-generator'
|
||||||
? `Slides — ${(note.title || 'Note').substring(0, 40)}`
|
? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||||
: `Diagramme — ${(note.title || 'Note').substring(0, 40)}`
|
: `${isEn ? 'Diagram' : 'Diagramme'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||||
|
|
||||||
const agent = await prisma.agent.create({
|
const agent = await prisma.agent.create({
|
||||||
data: {
|
data: {
|
||||||
name: agentName,
|
name: agentName,
|
||||||
type,
|
type,
|
||||||
role: defaults.role,
|
role,
|
||||||
tools: JSON.stringify(defaults.tools),
|
tools: JSON.stringify(defaults.tools),
|
||||||
maxSteps: defaults.maxSteps,
|
maxSteps: defaults.maxSteps,
|
||||||
frequency: 'one-shot',
|
frequency: 'one-shot',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { existingContent, resourceText, mode, language } = await request.json()
|
const { existingContent, resourceText, mode, language, format } = await request.json()
|
||||||
|
|
||||||
if (!resourceText || typeof resourceText !== 'string') {
|
if (!resourceText || typeof resourceText !== 'string') {
|
||||||
return NextResponse.json({ error: 'resourceText is required' }, { status: 400 })
|
return NextResponse.json({ error: 'resourceText is required' }, { status: 400 })
|
||||||
@@ -20,6 +20,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lang = language || 'fr'
|
const lang = language || 'fr'
|
||||||
|
const outputFormat = format === 'html' ? 'HTML (with proper tags like <h2>, <p>, <ul>, <li>)' : 'Markdown (with ##, -, **, etc.)'
|
||||||
const config = await getSystemConfig()
|
const config = await getSystemConfig()
|
||||||
const provider = getTagsProvider(config)
|
const provider = getTagsProvider(config)
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export async function POST(request: NextRequest) {
|
|||||||
prompt = `You are an expert note editor. Your task is to enrich an existing note by adding relevant information from a provided resource, WITHOUT modifying or rewriting the existing content.
|
prompt = `You are an expert note editor. Your task is to enrich an existing note by adding relevant information from a provided resource, WITHOUT modifying or rewriting the existing content.
|
||||||
|
|
||||||
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
|
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
|
||||||
|
FORMAT RULE: Respond in ${outputFormat}.
|
||||||
|
|
||||||
EXISTING NOTE:
|
EXISTING NOTE:
|
||||||
---
|
---
|
||||||
@@ -46,13 +48,14 @@ INSTRUCTIONS:
|
|||||||
- Append ONLY new, non-redundant information from the resource below the existing content
|
- Append ONLY new, non-redundant information from the resource below the existing content
|
||||||
- Use a clear separator (e.g., "---" or a new section heading) between existing and new content
|
- Use a clear separator (e.g., "---" or a new section heading) between existing and new content
|
||||||
- Skip information already covered in the existing note
|
- Skip information already covered in the existing note
|
||||||
- Format the new content consistently with the existing note style
|
- Format the new content consistently with the existing note style and the requested FORMAT RULE
|
||||||
- Respond ONLY with the enriched note content, no explanations`
|
- Respond ONLY with the enriched note content, no explanations`
|
||||||
} else {
|
} else {
|
||||||
// Merge: intelligently rewrite integrating both sources
|
// Merge: intelligently rewrite integrating both sources
|
||||||
prompt = `You are an expert note writer. Your task is to intelligently merge an existing note with a resource into a single, coherent, well-structured document.
|
prompt = `You are an expert note writer. Your task is to intelligently merge an existing note with a resource into a single, coherent, well-structured document.
|
||||||
|
|
||||||
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
|
LANGUAGE RULE: Respond in ${lang}. Match the language of the existing note.
|
||||||
|
FORMAT RULE: Respond in ${outputFormat}.
|
||||||
|
|
||||||
EXISTING NOTE:
|
EXISTING NOTE:
|
||||||
---
|
---
|
||||||
@@ -69,7 +72,7 @@ INSTRUCTIONS:
|
|||||||
- Eliminate redundancy — include each piece of information only once
|
- Eliminate redundancy — include each piece of information only once
|
||||||
- Preserve the key ideas from both sources
|
- Preserve the key ideas from both sources
|
||||||
- Maintain a logical structure with clear headings if appropriate
|
- Maintain a logical structure with clear headings if appropriate
|
||||||
- Keep the tone and style consistent
|
- Keep the tone and style consistent with the requested FORMAT RULE
|
||||||
- Respond ONLY with the merged content, no meta-commentary or explanations`
|
- Respond ONLY with the merged content, no meta-commentary or explanations`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { prisma } from '@/lib/prisma'
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||||
import { toolRegistry } from '@/lib/ai/tools'
|
import { toolRegistry } from '@/lib/ai/tools'
|
||||||
import { stepCountIs } from 'ai'
|
|
||||||
import { readFile } from 'fs/promises'
|
import { readFile } from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
@@ -47,36 +46,32 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
// 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport
|
// 2. Parse request body
|
||||||
const body = await req.json()
|
const body = await req.json()
|
||||||
|
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext, format } = body as {
|
||||||
const { messages: rawMessages, conversationId, notebookId, language, webSearch, noteContext } = body as {
|
|
||||||
messages: UIMessage[]
|
messages: UIMessage[]
|
||||||
conversationId?: string
|
conversationId?: string
|
||||||
notebookId?: string
|
notebookId?: string
|
||||||
language?: string
|
language?: string
|
||||||
webSearch?: boolean
|
webSearch?: boolean
|
||||||
noteContext?: { title: string; content: string; tone: string; images?: string[] }
|
noteContext?: { title: string; content: string; tone: string; images?: string[] }
|
||||||
|
format?: 'html' | 'markdown'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert UIMessages to CoreMessages for streamText
|
|
||||||
const incomingMessages = toCoreMessages(rawMessages)
|
const incomingMessages = toCoreMessages(rawMessages)
|
||||||
|
|
||||||
// 3. Manage conversation (create or fetch)
|
// 3. Manage conversation
|
||||||
let conversation: { id: string; messages: Array<{ role: string; content: string }> }
|
let conversation: { id: string; messages: Array<{ role: string; content: string }> }
|
||||||
|
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
const existing = await prisma.conversation.findUnique({
|
const existing = await prisma.conversation.findUnique({
|
||||||
where: { id: conversationId, userId },
|
where: { id: conversationId, userId },
|
||||||
include: { messages: { orderBy: { createdAt: 'asc' } } },
|
include: { messages: { orderBy: { createdAt: 'asc' } } },
|
||||||
})
|
})
|
||||||
if (!existing) {
|
if (!existing) return new Response('Conversation not found', { status: 404 })
|
||||||
return new Response('Conversation not found', { status: 404 })
|
|
||||||
}
|
|
||||||
conversation = existing
|
conversation = existing
|
||||||
} else {
|
} else {
|
||||||
const userMessage = incomingMessages[incomingMessages.length - 1]?.content || 'New conversation'
|
const userMessage = incomingMessages[incomingMessages.length - 1]?.content || 'New conversation'
|
||||||
const created = await prisma.conversation.create({
|
conversation = await prisma.conversation.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
notebookId: notebookId || null,
|
notebookId: notebookId || null,
|
||||||
@@ -84,33 +79,21 @@ export async function POST(req: Request) {
|
|||||||
},
|
},
|
||||||
include: { messages: true },
|
include: { messages: true },
|
||||||
})
|
})
|
||||||
conversation = created
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. RAG retrieval
|
// 4. RAG retrieval
|
||||||
const currentMessage = incomingMessages[incomingMessages.length - 1]?.content || ''
|
const currentMessage = incomingMessages[incomingMessages.length - 1]?.content || ''
|
||||||
|
|
||||||
// Load translations for the requested language
|
|
||||||
const lang = (language || 'en') as SupportedLanguage
|
const lang = (language || 'en') as SupportedLanguage
|
||||||
const translations = await loadTranslations(lang)
|
const translations = await loadTranslations(lang)
|
||||||
const untitledText = getTranslationValue(translations, 'notes.untitled') || 'Untitled'
|
const untitledText = getTranslationValue(translations, 'notes.untitled') || 'Untitled'
|
||||||
|
|
||||||
// If a notebook is selected, fetch its recent notes directly as context
|
|
||||||
// This ensures the AI always has access to the notebook content,
|
|
||||||
// even for vague queries like "what's in this notebook?"
|
|
||||||
let notebookContext = ''
|
let notebookContext = ''
|
||||||
let searchNotes = ''
|
let searchNotes = ''
|
||||||
|
|
||||||
// When scope is "this note" (noteContext present), skip RAG retrieval entirely
|
|
||||||
// The note content is already injected as copilotContext below
|
|
||||||
if (!noteContext) {
|
if (!noteContext) {
|
||||||
if (notebookId) {
|
if (notebookId) {
|
||||||
const notebookNotes = await prisma.note.findMany({
|
const notebookNotes = await prisma.note.findMany({
|
||||||
where: {
|
where: { notebookId, userId, trashedAt: null },
|
||||||
notebookId,
|
|
||||||
userId,
|
|
||||||
trashedAt: null,
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
take: 20,
|
take: 20,
|
||||||
select: { id: true, title: true, content: true, updatedAt: true },
|
select: { id: true, title: true, content: true, updatedAt: true },
|
||||||
@@ -122,7 +105,6 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also run semantic search for the specific query
|
|
||||||
let searchResults: any[] = []
|
let searchResults: any[] = []
|
||||||
try {
|
try {
|
||||||
searchResults = await semanticSearchService.search(currentMessage, {
|
searchResults = await semanticSearchService.search(currentMessage, {
|
||||||
@@ -131,21 +113,16 @@ export async function POST(req: Request) {
|
|||||||
threshold: notebookId ? 0.3 : 0.5,
|
threshold: notebookId ? 0.3 : 0.5,
|
||||||
defaultTitle: untitledText,
|
defaultTitle: untitledText,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {}
|
||||||
// Search failure should not block chat
|
|
||||||
}
|
|
||||||
|
|
||||||
searchNotes = searchResults
|
searchNotes = searchResults
|
||||||
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
||||||
.join('\n\n---\n\n')
|
.join('\n\n---\n\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine: full notebook context + semantic search results (deduplicated)
|
|
||||||
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
|
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
|
||||||
|
|
||||||
// 5. System prompt synthesis with RAG context
|
// 5. System prompt synthesis
|
||||||
// Language-aware prompts to avoid forcing French responses
|
|
||||||
// Note: lang is already declared above when loading translations
|
|
||||||
const promptLang: Record<string, { contextWithNotes: string; contextNoNotes: string; system: string }> = {
|
const promptLang: Record<string, { contextWithNotes: string; contextNoNotes: string; system: string }> = {
|
||||||
en: {
|
en: {
|
||||||
contextWithNotes: `## User's notes\n\n${contextNotes}\n\nWhen using info from the notes above, cite the source note title in parentheses, e.g.: "Deployment is done via Docker (💻 Development Guide)". Don't copy word for word — rephrase. If the notes don't cover the topic, say so and supplement with your general knowledge.`,
|
contextWithNotes: `## User's notes\n\n${contextNotes}\n\nWhen using info from the notes above, cite the source note title in parentheses, e.g.: "Deployment is done via Docker (💻 Development Guide)". Don't copy word for word — rephrase. If the notes don't cover the topic, say so and supplement with your general knowledge.`,
|
||||||
@@ -153,14 +130,24 @@ export async function POST(req: Request) {
|
|||||||
system: `You are the AI assistant of Memento. The user asks you questions about their projects, technical docs, and notes. You must respond in a structured and helpful way.
|
system: `You are the AI assistant of Memento. The user asks you questions about their projects, technical docs, and notes. You must respond in a structured and helpful way.
|
||||||
|
|
||||||
## Format rules
|
## Format rules
|
||||||
- Use markdown freely: headings (##, ###), lists, code blocks, bold, tables — anything that makes the response readable.
|
- ${format === 'html' ? `Respond MANDATORILY using valid HTML fragments (e.g., <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
|
||||||
|
- Do NOT use Markdown symbols (no #, *, -, etc.).
|
||||||
|
- Do not wrap your HTML code in a Markdown code block.` : 'Use markdown freely: headings (##, ###), lists, code blocks, bold, tables — anything that makes the response readable.'}
|
||||||
- Structure your response with sections for technical questions or complex topics.
|
- Structure your response with sections for technical questions or complex topics.
|
||||||
- For simple, short questions, a direct paragraph is enough.
|
- For simple, short questions, a direct paragraph is enough.` + (format === 'html' ? `
|
||||||
|
|
||||||
|
## HTML OUTPUT EXAMPLE
|
||||||
|
<h3>Section Title</h3>
|
||||||
|
<p>Here is an explanation with <strong>bold text</strong> and a list:</p>
|
||||||
|
<ul>
|
||||||
|
<li>First important point</li>
|
||||||
|
<li>Second important point</li>
|
||||||
|
</ul>` : '') + `
|
||||||
|
|
||||||
## Tone rules
|
## Tone rules
|
||||||
- Natural tone, neither corporate nor too casual.
|
- Natural tone, neither corporate nor too casual.
|
||||||
- No unnecessary intro phrases ("Here's what I found", "Based on your notes"). Answer directly.
|
- No unnecessary intro phrases. Answer directly.
|
||||||
- No upsell questions at the end ("Would you like me to...", "Do you want..."). If you have useful additional info, just give it.
|
- No upsell questions at the end. If you have useful additional info, just give it.
|
||||||
- If the user says "Momento" they mean Momento (this app).
|
- If the user says "Momento" they mean Momento (this app).
|
||||||
|
|
||||||
## About Momento
|
## About Momento
|
||||||
@@ -170,171 +157,90 @@ Momento is an intelligent note-taking application. Key features include:
|
|||||||
- **Search**: Advanced semantic search to find notes by meaning, not just keywords, and Web Search integration.
|
- **Search**: Advanced semantic search to find notes by meaning, not just keywords, and Web Search integration.
|
||||||
- **Agents**: Create specialized AI Agents with custom system prompts for specific recurring tasks.
|
- **Agents**: Create specialized AI Agents with custom system prompts for specific recurring tasks.
|
||||||
- **Lab**: Experimental AI tools for data analysis and deeper insights.
|
- **Lab**: Experimental AI tools for data analysis and deeper insights.
|
||||||
If the user asks how to use this tool, explain these features simply and helpfully.
|
|
||||||
|
|
||||||
## Available tools
|
## Available tools
|
||||||
You have access to these tools for deeper research:
|
You have access to: note_search, note_read, web_search, web_scrape.
|
||||||
- **note_search**: Search the user's notes by keyword or meaning. Use when the initial context above is insufficient or when the user asks about specific content in their notes. If a notebook is selected, pass its ID to restrict results.
|
Only use tools if you need more information. Never invent note IDs or URLs.`,
|
||||||
- **note_read**: Read a specific note by ID. Use when note_search returns a note you need the full content of.
|
|
||||||
- **web_search**: Search the web for information. Use when the user asks about something not in their notes.
|
|
||||||
- **web_scrape**: Scrape a web page and return its content as markdown. Use when web_search returns a URL you need to read.
|
|
||||||
|
|
||||||
## Tool usage rules
|
|
||||||
- You already have context from the user's notes above. Only use tools if you need more specific or additional information.
|
|
||||||
- Never invent note IDs, URLs, or notebook IDs. Use the IDs provided in the context or from tool results.
|
|
||||||
- For simple conversational questions (greetings, opinions, general knowledge), answer directly without using any tools.`,
|
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
contextWithNotes: `## Notes de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Ne recopie pas mot pour mot — reformule. Si les notes ne couvrent pas le sujet, dis-le et complète avec tes connaissances générales.`,
|
contextWithNotes: `## Notes de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Ne recopie pas mot pour mot — reformule.`,
|
||||||
contextNoNotes: "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
contextNoNotes: "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
||||||
system: `Tu es l'assistant IA de Memento. L'utilisateur te pose des questions sur ses projets, sa doc technique, ses notes. Tu dois répondre de façon structurée et utile.
|
system: `Tu es l'assistant IA de Memento. L'utilisateur te pose des questions sur ses projets, sa doc technique, ses notes. Tu dois répondre de façon structurée et utile.
|
||||||
|
|
||||||
## Règles de format
|
## Règles de format
|
||||||
- Utilise le markdown librement : titres (##, ###), listes, code blocks, gras, tables — tout ce qui rend la réponse lisible.
|
- ${format === 'html' ? `Réponds OBLIGATOIREMENT en utilisant des fragments HTML valides (ex: <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
|
||||||
|
- N'utilise PAS de symboles Markdown.
|
||||||
|
- Ne mets pas ton code HTML dans un bloc de code Markdown.` : '- Utilise le markdown librement : titres (##, ###), listes, code blocks, gras, tables.'}
|
||||||
- Structure ta réponse avec des sections quand c'est une question technique ou un sujet complexe.
|
- Structure ta réponse avec des sections quand c'est une question technique ou un sujet complexe.
|
||||||
- Pour les questions simples et courtes, un paragraphe direct suffit.
|
- Pour les questions simples et courtes, un paragraphe direct suffit.` + (format === 'html' ? `
|
||||||
|
|
||||||
|
## EXEMPLE DE SORTIE HTML
|
||||||
|
<h3>Titre de section</h3>
|
||||||
|
<p>Voici une explication avec du <strong>texte en gras</strong> et une liste :</p>
|
||||||
|
<ul>
|
||||||
|
<li>Premier point important</li>
|
||||||
|
<li>Deuxième point important</li>
|
||||||
|
</ul>` : '') + `
|
||||||
|
|
||||||
## Règles de ton
|
## Règles de ton
|
||||||
- Ton naturel, ni corporate ni trop familier.
|
- Ton naturel, direct, sans phrases d'intro inutiles.
|
||||||
- Pas de phrase d'intro inutile ("Voici ce que j'ai trouvé", "Basé sur vos notes"). Réponds directement.
|
- Pas de question upsell à la fin.
|
||||||
- Pas de question upsell à la fin ("Souhaitez-vous que je...", "Acceptez-vous que..."). Si tu as une info complémentaire utile, donne-la.
|
|
||||||
- Si l'utilisateur dit "Momento" il parle de Momento (cette application).
|
- Si l'utilisateur dit "Momento" il parle de Momento (cette application).
|
||||||
|
|
||||||
## À propos de Momento
|
## À propos de Momento
|
||||||
Momento est une application de prise de notes intelligente. Ses fonctionnalités principales :
|
Momento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
|
||||||
- **Éditeur de notes** : Prise de notes en Markdown riche avec un Copilot IA intégré pour réécrire, résumer ou traduire du texte.
|
|
||||||
- **Organisation** : Regroupement des notes dans des Carnets (Notebooks) et utilisation d'Étiquettes (Labels).
|
|
||||||
- **Recherche** : Recherche sémantique avancée pour trouver des notes par le sens, et recherche Web intégrée.
|
|
||||||
- **Agents** : Création d'Agents IA spécialisés avec des instructions personnalisées pour des tâches récurrentes.
|
|
||||||
- **Lab** : Outils IA expérimentaux pour l'analyse de données et les insights.
|
|
||||||
Si l'utilisateur demande comment utiliser cet outil, explique ces fonctionnalités simplement et avec bienveillance.
|
|
||||||
|
|
||||||
## Outils disponibles
|
## Outils disponibles
|
||||||
Tu as accès à ces outils pour des recherches approfondies :
|
Tu as accès à : note_search, note_read, web_search, web_scrape.`,
|
||||||
- **note_search** : Cherche dans les notes de l'utilisateur par mot-clé ou sens. Utilise quand le contexte initial ci-dessus est insuffisant ou quand l'utilisateur demande du contenu spécifique dans ses notes. Si un carnet est sélectionné, passe son ID pour restreindre les résultats.
|
|
||||||
- **note_read** : Lit une note spécifique par son ID. Utilise quand note_search retourne une note dont tu as besoin du contenu complet.
|
|
||||||
- **web_search** : Recherche sur le web. Utilise quand l'utilisateur demande quelque chose qui n'est pas dans ses notes.
|
|
||||||
- **web_scrape** : Scrape une page web et retourne son contenu en markdown. Utilise quand web_search retourne une URL que tu veux lire.
|
|
||||||
|
|
||||||
## Règles d'utilisation des outils
|
|
||||||
- Tu as déjà du contexte des notes de l'utilisateur ci-dessus. Utilise les outils seulement si tu as besoin d'informations plus spécifiques.
|
|
||||||
- N'invente jamais d'IDs de notes, d'URLs ou d'IDs de carnet. Utilise les IDs fournis dans le contexte ou les résultats d'outils.
|
|
||||||
- Pour les questions conversationnelles simples (salutations, opinions, connaissances générales), réponds directement sans utiliser d'outils.`,
|
|
||||||
},
|
},
|
||||||
fa: {
|
fa: {
|
||||||
contextWithNotes: `## یادداشتهای کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشتهای بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید. کپی نکنید — بازنویسی کنید. اگر یادداشتها موضوع را پوشش نمیدهند، بگویید و با دانش عمومی خود تکمیل کنید.`,
|
contextWithNotes: `## یادداشتهای کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشتهای بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید.`,
|
||||||
contextNoNotes: "هیچ یادداشت مرتبطی برای این سؤال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
contextNoNotes: "هیچ یادداشت مرتبطی برای این سؤال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
||||||
system: `شما دستیار هوش مصنوعی Memento هستید. کاربر از شما درباره پروژهها، مستندات فنی و یادداشتهایش سؤال میکند. باید به شکلی ساختاریافته و مفید پاسخ دهید.
|
system: `شما دستیار هوش مصنوعی Memento هستید. کاربر از شما درباره پروژهها، مستندات فنی و یادداشتهایش سؤال میکند. باید به شکلی ساختاریافته و مفید پاسخ دهید.
|
||||||
|
|
||||||
## قوانین قالببندی
|
## قوانین قالببندی
|
||||||
- از مارکداون آزادانه استفاده کنید: عناوین (##, ###)، لیستها، بلوکهای کد، پررنگ، جداول.
|
- ${format === 'html' ? `حتماً از تگهای HTML معتبر استفاده کنید (مانند <p>, <strong>, <em>, <ul>, <li>, <h3>).
|
||||||
|
- از نمادهای مارکداون استفاده نکنید.` : 'از مارکداون آزادانه استفاده کنید: عناوین (##, ###)، لیستها، بلوکهای کد، پررنگ، جداول.'}
|
||||||
- برای سؤالات فنی یا موضوعات پیچیده، پاسخ خود را بخشبندی کنید.
|
- برای سؤالات فنی یا موضوعات پیچیده، پاسخ خود را بخشبندی کنید.
|
||||||
- برای سؤالات ساده و کوتاه، یک پاراگراف مستقیم کافی است.
|
- برای سؤالات ساده و کوتاه، یک پاراگراف مستقیم کافی است.` + (format === 'html' ? `
|
||||||
|
|
||||||
|
## نمونه خروجی HTML
|
||||||
|
<h3>عنوان بخش</h3>
|
||||||
|
<p>این یک توضیح با <strong>متن برجسته</strong> و یک لیست است:</p>
|
||||||
|
<ul>
|
||||||
|
<li>نکته اول</li>
|
||||||
|
<li>نکته دوم</li>
|
||||||
|
</ul>` : '') + `
|
||||||
|
|
||||||
## قوانین لحن
|
## قوانین لحن
|
||||||
- لحن طبیعی، نه رسمی بیش از حد و نه خیلی غیررسمی.
|
- لحن طبیعی، مستقیم، بدون مقدمه اضافی.
|
||||||
- بدون جمله مقدمه اضافی. مستقیم پاسخ دهید.
|
- اگر کاربر "Momento" میگوید، منظورش Memento (این برنامه) است.`,
|
||||||
- بدون سؤال فروشی در انتها. اگر اطلاعات تکمیلی مفید دارید، مستقیم بدهید.
|
|
||||||
- اگر کاربر "Momento" میگوید، منظورش Memento (این برنامه) است.
|
|
||||||
|
|
||||||
## ابزارهای موجود
|
|
||||||
- **note_search**: جستجو در یادداشتهای کاربر با کلیدواژه یا معنی. زمانی استفاده کنید که زمینه اولیه کافی نباشد. اگر دفترچه انتخاب شده، شناسه آن را ارسال کنید.
|
|
||||||
- **note_read**: خواندن یک یادداشت خاص با شناسه. زمانی استفاده کنید که note_search یادداشتی برگرداند که محتوای کامل آن را نیاز دارید.
|
|
||||||
- **web_search**: جستجو در وب. زمانی استفاده کنید که کاربر درباره چیزی خارج از یادداشتهایش میپرسد.
|
|
||||||
- **web_scrape**: استخراج محتوای صفحه وب. زمانی استفاده کنید که web_search نشانیای برگرداند که میخواهید بخوانید.
|
|
||||||
|
|
||||||
## قوانین استفاده از ابزارها
|
|
||||||
- شما از قبل زمینهای از یادداشتهای کاربر دارید. فقط در صورت نیاز به اطلاعات بیشتر از ابزارها استفاده کنید.
|
|
||||||
- هرگز شناسه یادداشت، نشانی یا شناسه دفترچه نسازید. از شناسههای موجود در زمینه یا نتایج ابزار استفاده کنید.
|
|
||||||
- برای سؤالات مکالمهای ساده (سلام، نظرات، دانش عمومی)، مستقیم پاسخ دهید.`,
|
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis. No copies palabra por palabra — reformula. Si las notas no cubren el tema, dilo y complementa con tu conocimiento general.`,
|
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis.`,
|
||||||
contextNoNotes: "No se encontraron notas relevantes para esta pregunta. Responde con tu conocimiento general.",
|
contextNoNotes: "No se encontraron notas relevantes para esta pregunta. Responde con tu conocimiento general.",
|
||||||
system: `Eres el asistente de IA de Memento. El usuario te hace preguntas sobre sus proyectos, documentación técnica y notas. Debes responder de forma estructurada y útil.
|
system: `Eres el asistente de IA de Memento. El usuario te hace preguntas sobre sus proyectos, documentación técnica y notas.
|
||||||
|
|
||||||
## Reglas de formato
|
## Reglas de formato
|
||||||
- Usa markdown libremente: títulos (##, ###), listas, bloques de código, negritas, tablas.
|
- ${format === 'html' ? `Responde OBLIGATORIAMENTE usando fragmentos HTML válidos (ej: <p>, <strong>, <em>, <ul>, <li>, <h3>, <table>, <tr>, <td>).
|
||||||
- Estructura tu respuesta con secciones para preguntas técnicas o temas complejos.
|
- NO uses símbolos Markdown.` : 'Usa markdown libremente: títulos (##, ###), listas, negritas, tablas.'}
|
||||||
- Para preguntas simples y cortas, un párrafo directo es suficiente.
|
- Estructura tu respuesta con secciones para temas complejos.
|
||||||
|
- Para preguntas simples, un párrafo directo es suficiente.` + (format === 'html' ? `
|
||||||
|
|
||||||
## Reglas de tono
|
## EJEMPLO DE SALIDA HTML
|
||||||
- Tono natural, ni corporativo ni demasiado informal.
|
<h3>Título de sección</h3>
|
||||||
- Sin frases de introducción innecesarias. Responde directamente.
|
<p>Aquí hay una explicación con <strong>texto en negrita</strong> y una lista:</p>
|
||||||
- Sin preguntas de venta al final. Si tienes información complementaria útil, dala directamente.
|
<ul>
|
||||||
|
<li>Primer punto importante</li>
|
||||||
## Herramientas disponibles
|
<li>Segundo punto importante</li>
|
||||||
- **note_search**: Busca en las notas del usuario por palabra clave o significado. Úsalo cuando el contexto inicial sea insuficiente. Si hay una libreta seleccionada, pasa su ID para restringir los resultados.
|
</ul>` : ''),
|
||||||
- **note_read**: Lee una nota específica por su ID. Úsalo cuando note_search devuelva una nota cuyo contenido completo necesites.
|
|
||||||
- **web_search**: Busca en la web. Úsalo cuando el usuario pregunte sobre algo que no está en sus notas.
|
|
||||||
- **web_scrape**: Extrae el contenido de una página web como markdown. Úsalo cuando web_search devuelva una URL que quieras leer.
|
|
||||||
|
|
||||||
## Reglas de uso de herramientas
|
|
||||||
- Ya tienes contexto de las notas del usuario arriba. Solo usa herramientas si necesitas información más específica.
|
|
||||||
- Nunca inventes IDs de notas, URLs o IDs de libreta. Usa los IDs proporcionados en el contexto o en los resultados de herramientas.
|
|
||||||
- Para preguntas conversacionales simples (saludos, opiniones, conocimiento general), responde directamente sin herramientas.`,
|
|
||||||
},
|
|
||||||
de: {
|
|
||||||
contextWithNotes: `## Notizen des Benutzers\n\n${contextNotes}\n\nWenn du Infos aus den obigen Notizen verwendest, zitiere den Titel der Quellnotiz in Klammern. Nicht Wort für Wort kopieren — umformulieren. Wenn die Notizen das Thema nicht abdecken, sag es und ergänze mit deinem Allgemeinwissen.`,
|
|
||||||
contextNoNotes: "Keine relevanten Notizen für diese Frage gefunden. Antworte mit deinem Allgemeinwissen.",
|
|
||||||
system: `Du bist der KI-Assistent von Memento. Der Benutzer stellt dir Fragen zu seinen Projekten, technischen Dokumentationen und Notizen. Du musst strukturiert und hilfreich antworten.
|
|
||||||
|
|
||||||
## Formatregeln
|
|
||||||
- Verwende Markdown frei: Überschriften (##, ###), Listen, Code-Blöcke, Fettdruck, Tabellen.
|
|
||||||
- Strukturiere deine Antwort mit Abschnitten bei technischen Fragen oder komplexen Themen.
|
|
||||||
- Bei einfachen, kurzen Fragen reicht ein direkter Absatz.
|
|
||||||
|
|
||||||
## Tonregeln
|
|
||||||
- Natürlicher Ton, weder zu geschäftsmäßig noch zu umgangssprachlich.
|
|
||||||
- Keine unnötigen Einleitungssätze. Antworte direkt.
|
|
||||||
- Keine Upsell-Fragen am Ende. Gib nützliche Zusatzinfos einfach direkt.
|
|
||||||
|
|
||||||
## Verfügbare Werkzeuge
|
|
||||||
- **note_search**: Durchsuche die Notizen des Benutzers nach Schlagwort oder Bedeutung. Verwende es, wenn der obige Kontext unzureichend ist. Wenn ein Notizbuch ausgewählt ist, gib dessen ID an, um die Ergebnisse einzuschränken.
|
|
||||||
- **note_read**: Lese eine bestimmte Notiz anhand ihrer ID. Verwende es, wenn note_search eine Notiz zurückgibt, deren vollständigen Inhalt du benötigst.
|
|
||||||
- **web_search**: Suche im Web. Verwende es, wenn der Benutzer nach etwas fragt, das nicht in seinen Notizen steht.
|
|
||||||
- **web_scrape**: Lese eine Webseite und gib den Inhalt als Markdown zurück. Verwende es, wenn web_search eine URL zurückgibt, die du lesen möchtest.
|
|
||||||
|
|
||||||
## Werkzeugregeln
|
|
||||||
- Du hast bereits Kontext aus den Notizen des Benutzers oben. Verwende Werkzeuge nur, wenn du spezifischere Informationen benötigst.
|
|
||||||
- Erfinde niemals Notiz-IDs, URLs oder Notizbuch-IDs. Verwende die im Kontext oder in Werkzeugergebnissen bereitgestellten IDs.
|
|
||||||
- Bei einfachen Gesprächsfragen (Begrüßungen, Meinungen, Allgemeinwissen) antworte direkt ohne Werkzeuge.`,
|
|
||||||
},
|
|
||||||
it: {
|
|
||||||
contextWithNotes: `## Note dell'utente\n\n${contextNotes}\n\nQuando usi informazioni dalle note sopra, cita il titolo della nota fonte tra parentesi. Non copiare parola per parola — riformula. Se le note non coprono l'argomento, dillo e integra con la tua conoscenza generale.`,
|
|
||||||
contextNoNotes: "Nessuna nota rilevante trovata per questa domanda. Rispondi con la tua conoscenza generale.",
|
|
||||||
system: `Sei l'assistente IA di Memento. L'utente ti fa domande sui suoi progetti, documentazione tecnica e note. Devi rispondere in modo strutturato e utile.
|
|
||||||
|
|
||||||
## Regole di formato
|
|
||||||
- Usa markdown liberamente: titoli (##, ###), elenchi, blocchi di codice, grassetto, tabelle.
|
|
||||||
- Struttura la risposta con sezioni per domande tecniche o argomenti complessi.
|
|
||||||
- Per domande semplici e brevi, un paragrafo diretto basta.
|
|
||||||
|
|
||||||
## Regole di tono
|
|
||||||
- Tono naturale, né aziendale né troppo informale.
|
|
||||||
- Nessuna frase introduttiva non necessaria. Rispondi direttamente.
|
|
||||||
- Nessuna domanda di upsell alla fine. Se hai informazioni aggiuntive utili, dalle direttamente.
|
|
||||||
|
|
||||||
## Strumenti disponibili
|
|
||||||
- **note_search**: Cerca nelle note dell'utente per parola chiave o significato. Usa quando il contesto iniziale è insufficiente. Se un quaderno è selezionato, passa il suo ID per restringere i risultati.
|
|
||||||
- **note_read**: Leggi una nota specifica per ID. Usa quando note_search restituisce una nota di cui hai bisogno del contenuto completo.
|
|
||||||
- **web_search**: Cerca sul web. Usa quando l'utente chiede qualcosa che non è nelle sue note.
|
|
||||||
- **web_scrape**: Estrai il contenuto di una pagina web come markdown. Usa quando web_search restituisce un URL che vuoi leggere.
|
|
||||||
|
|
||||||
## Regole di utilizzo degli strumenti
|
|
||||||
- Hai già contesto dalle note dell'utente sopra. Usa gli strumenti solo se hai bisogno di informazioni più specifiche.
|
|
||||||
- Non inventare mai ID di note, URL o ID di quaderno. Usa gli ID forniti nel contesto o nei risultati degli strumenti.
|
|
||||||
- Per domande conversazionali semplici (saluti, opinioni, conoscenza generale), rispondi direttamente senza strumenti.`,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to English if language not supported
|
|
||||||
const prompts = promptLang[lang] || promptLang.en
|
const prompts = promptLang[lang] || promptLang.en
|
||||||
const contextBlock = contextNotes.length > 0
|
const contextBlock = contextNotes.length > 0 ? prompts.contextWithNotes : prompts.contextNoNotes
|
||||||
? prompts.contextWithNotes
|
|
||||||
: prompts.contextNoNotes
|
|
||||||
|
|
||||||
// Load note images as base64 for vision-capable models
|
// Load note images for vision
|
||||||
let imageContextParts: Array<{ type: 'image'; image: string }> = []
|
let imageContextParts: Array<{ type: 'image'; image: string }> = []
|
||||||
if (noteContext?.images && noteContext.images.length > 0) {
|
if (noteContext?.images && noteContext.images.length > 0) {
|
||||||
for (const imgPath of noteContext.images.slice(0, 4)) {
|
for (const imgPath of noteContext.images.slice(0, 4)) {
|
||||||
@@ -343,8 +249,7 @@ Tu as accès à ces outils pour des recherches approfondies :
|
|||||||
const buffer = await readFile(fullPath)
|
const buffer = await readFile(fullPath)
|
||||||
const ext = path.extname(imgPath).toLowerCase()
|
const ext = path.extname(imgPath).toLowerCase()
|
||||||
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
|
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
|
||||||
const base64 = `data:${mime};base64,${buffer.toString('base64')}`
|
imageContextParts.push({ type: 'image', image: `data:${mime};base64,${buffer.toString('base64')}` })
|
||||||
imageContextParts.push({ type: 'image', image: base64 })
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,113 +257,37 @@ Tu as accès à ces outils pour des recherches approfondies :
|
|||||||
let copilotContext = ''
|
let copilotContext = ''
|
||||||
if (noteContext) {
|
if (noteContext) {
|
||||||
copilotContext = `\n\n## Current Note Context
|
copilotContext = `\n\n## Current Note Context
|
||||||
You are currently helping the user edit a specific note. Here is the current content of the note:
|
You are helping the user edit a specific note: ${noteContext.title || 'Untitled'}.
|
||||||
Title: ${noteContext.title || 'Untitled'}
|
Tone: ${noteContext.tone || 'professional'}.
|
||||||
|
Content: ${noteContext.content || '(empty)'}
|
||||||
Content:
|
Focus ONLY on this note unless asked otherwise.`
|
||||||
${noteContext.content || '(empty)'}
|
|
||||||
${imageContextParts.length > 0 ? `\nImages: ${imageContextParts.length} image(s) attached. When the user asks about images, describe what you see in them.` : ''}
|
|
||||||
|
|
||||||
The user wants you to write in a **${noteContext.tone || 'professional'}** tone.
|
|
||||||
IMPORTANT: Focus ONLY on this note. Do NOT reference other notes or external information unless the user explicitly asks. Your job is to help with this specific note — suggest rewrites, answer questions about it, or draft new sections.`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemPrompt = `${prompts.system}
|
const systemPrompt = `${prompts.system}\n${copilotContext}\n\n${contextBlock}\n\n## LANGUAGE RULE (MANDATORY)\nYou MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : 'English'}.`
|
||||||
${copilotContext}
|
|
||||||
|
|
||||||
${contextBlock}
|
// 6. Execute stream
|
||||||
|
const sysConfig = await getSystemConfig()
|
||||||
## LANGUAGE RULE (MANDATORY)
|
|
||||||
You MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : lang === 'de' ? 'German' : lang === 'it' ? 'Italian' : 'English'}.
|
|
||||||
Never switch to another language. Even if the user writes in a different language, respond in the configured language.`
|
|
||||||
|
|
||||||
// 6. Build message history from DB + current messages
|
|
||||||
const dbHistory = conversation.messages.map((m: { role: string; content: string }) => ({
|
|
||||||
role: m.role as 'user' | 'assistant' | 'system',
|
|
||||||
content: m.content,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Only add the current user message if it's not already in DB history
|
|
||||||
const lastIncoming = incomingMessages[incomingMessages.length - 1]
|
|
||||||
const currentDbMessage = dbHistory[dbHistory.length - 1]
|
|
||||||
const isNewMessage =
|
|
||||||
lastIncoming &&
|
|
||||||
(!currentDbMessage ||
|
|
||||||
currentDbMessage.role !== 'user' ||
|
|
||||||
currentDbMessage.content !== lastIncoming.content)
|
|
||||||
|
|
||||||
let allMessages: Array<{ role: 'user' | 'assistant' | 'system'; content: string | Array<any> }> = isNewMessage
|
|
||||||
? [...dbHistory, { role: lastIncoming.role, content: lastIncoming.content }]
|
|
||||||
: dbHistory
|
|
||||||
|
|
||||||
// Inject note images as a context message for vision models
|
|
||||||
if (imageContextParts.length > 0) {
|
|
||||||
allMessages = [
|
|
||||||
{ role: 'user', content: [{ type: 'text' as const, text: '[Attached note images — use these when the user asks about images]' }, ...imageContextParts] },
|
|
||||||
{ role: 'assistant', content: 'Understood. I can see the attached images and will describe or analyze them when asked.' },
|
|
||||||
...allMessages,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sliding window: keep first 2 messages (context) + last 48 to avoid context overflow
|
|
||||||
const WINDOW = 50
|
|
||||||
if (allMessages.length > WINDOW) {
|
|
||||||
allMessages = [...allMessages.slice(0, 2), ...allMessages.slice(-(WINDOW - 2))]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Get chat provider model
|
|
||||||
const config = await getSystemConfig()
|
|
||||||
const provider = getChatProvider(config)
|
|
||||||
const model = provider.getModel()
|
|
||||||
|
|
||||||
// 7b. Build chat tools
|
|
||||||
const chatToolContext = {
|
|
||||||
userId,
|
|
||||||
conversationId: conversation.id,
|
|
||||||
notebookId,
|
|
||||||
webSearch: !!webSearch,
|
|
||||||
config,
|
|
||||||
}
|
|
||||||
// When scoped to "this note", only provide web tools — no note_search/note_read
|
|
||||||
// to prevent the AI from pulling information from other notes
|
|
||||||
const chatTools = noteContext
|
const chatTools = noteContext
|
||||||
? toolRegistry.buildToolsForChat({ ...chatToolContext, webOnly: true })
|
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, webOnly: true })
|
||||||
: toolRegistry.buildToolsForChat(chatToolContext)
|
: toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch })
|
||||||
|
|
||||||
// 8. Save user message to DB before streaming
|
const provider = getChatProvider(sysConfig)
|
||||||
if (isNewMessage && lastIncoming) {
|
const result = await streamText({
|
||||||
await prisma.chatMessage.create({
|
model: provider.getModel(),
|
||||||
data: {
|
|
||||||
conversationId: conversation.id,
|
|
||||||
role: 'user',
|
|
||||||
content: lastIncoming.content,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. Stream response
|
|
||||||
const result = streamText({
|
|
||||||
model,
|
|
||||||
system: systemPrompt,
|
system: systemPrompt,
|
||||||
messages: allMessages as any,
|
messages: incomingMessages,
|
||||||
tools: chatTools,
|
tools: chatTools,
|
||||||
stopWhen: stepCountIs(5),
|
maxSteps: 5,
|
||||||
async onFinish({ text }) {
|
onFinish: async (final) => {
|
||||||
// Save assistant message to DB after streaming completes
|
const userContent = incomingMessages[incomingMessages.length - 1].content
|
||||||
await prisma.chatMessage.create({
|
await prisma.chatMessage.create({
|
||||||
data: {
|
data: { conversationId: conversation.id, role: 'user', content: userContent }
|
||||||
conversationId: conversation.id,
|
|
||||||
role: 'assistant',
|
|
||||||
content: text,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
},
|
await prisma.chatMessage.create({
|
||||||
|
data: { conversationId: conversation.id, role: 'assistant', content: final.text }
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 10. Return streaming response with conversation ID header
|
return result.toUIMessageStreamResponse()
|
||||||
return result.toUIMessageStreamResponse({
|
|
||||||
headers: {
|
|
||||||
'X-Conversation-Id': conversation.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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, JetBrains_Mono } from "next/font/google";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -22,6 +24,18 @@ const manrope = Manrope({
|
|||||||
variable: "--font-manrope",
|
variable: "--font-manrope",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const playfair = Playfair_Display({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-memento-serif",
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-jetbrains-mono",
|
||||||
|
weight: ["400", "500"],
|
||||||
|
});
|
||||||
|
|
||||||
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 +52,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 {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +75,10 @@ const directionScript = `
|
|||||||
(function(){
|
(function(){
|
||||||
try {
|
try {
|
||||||
var lang = localStorage.getItem('user-language');
|
var lang = localStorage.getItem('user-language');
|
||||||
|
if (!lang) {
|
||||||
|
var c = document.cookie.split(';').map(function(s){return s.trim()}).find(function(s){return s.startsWith('user-language=')});
|
||||||
|
if (c) lang = c.split('=')[1];
|
||||||
|
}
|
||||||
if (lang === 'fa' || lang === 'ar') {
|
if (lang === 'fa' || lang === 'ar') {
|
||||||
document.documentElement.dir = 'rtl';
|
document.documentElement.dir = 'rtl';
|
||||||
document.documentElement.lang = lang;
|
document.documentElement.lang = lang;
|
||||||
@@ -77,10 +100,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} ${jetbrainsMono.variable}`}>
|
||||||
|
<Script
|
||||||
|
id="theme-early"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
dangerouslySetInnerHTML={{ __html: getThemeScript(userSettings.theme) }}
|
||||||
|
/>
|
||||||
<Script
|
<Script
|
||||||
id="sw-cleanup"
|
id="sw-cleanup"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { signOut, useSession } from 'next-auth/react'
|
|
||||||
import { Shield, Search, Settings, LogOut, User, StickyNote, FlaskConical, Bot } from 'lucide-react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { NotificationPanel } from './notification-panel'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admin header — visuellement identique au Header principal.
|
|
||||||
* Utilise exclusivement des <a> (rechargement complet) au lieu de <Link>
|
|
||||||
* pour éviter React Error #310 (bug React #33580 / Next.js #63388).
|
|
||||||
*/
|
|
||||||
export function AdminHeader() {
|
|
||||||
const { data: session } = useSession()
|
|
||||||
const { t } = useLanguage()
|
|
||||||
|
|
||||||
const user = session?.user
|
|
||||||
const initial = user?.name
|
|
||||||
? user.name.charAt(0).toUpperCase()
|
|
||||||
: user?.email?.[0]?.toUpperCase() ?? '?'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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">
|
|
||||||
{/* ── Logo + Search ── */}
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<a href="/" className="flex items-center gap-3 text-slate-900 dark:text-white group no-underline">
|
|
||||||
<div className="size-8 bg-primary rounded-lg flex items-center justify-center text-primary-foreground shadow-sm group-hover:shadow-md transition-all">
|
|
||||||
<StickyNote className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-bold leading-tight tracking-tight">MEMENTO</h2>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Badge Admin */}
|
|
||||||
<span className="hidden sm:flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-xs font-semibold">
|
|
||||||
<Shield className="h-3 w-3" />
|
|
||||||
Admin
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Search (décoratif en mode admin) — même taille que l'entête principale */}
|
|
||||||
<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">
|
|
||||||
<div className="text-slate-400 dark:text-slate-400 flex items-center justify-center pl-4">
|
|
||||||
<Search className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 px-3 text-sm focus:ring-0 focus:outline-none"
|
|
||||||
placeholder={t('search.placeholder') }
|
|
||||||
type="text"
|
|
||||||
disabled
|
|
||||||
aria-label={t('search.disabledAdmin')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Droite : nav + notifs + settings + avatar ── */}
|
|
||||||
<div className="flex flex-1 justify-end gap-2 items-center">
|
|
||||||
{/* Nav pills — toutes en <a> pour éviter la RSC race condition */}
|
|
||||||
<div className="hidden md:flex items-center gap-1 bg-slate-100 dark:bg-slate-800/60 rounded-full px-1.5 py-1">
|
|
||||||
<a
|
|
||||||
href="/agents"
|
|
||||||
className="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<Bot className="h-3.5 w-3.5" />
|
|
||||||
<span>{t('nav.agents') || 'Agents'}</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/lab"
|
|
||||||
className="flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<FlaskConical className="h-3.5 w-3.5" />
|
|
||||||
<span>{t('nav.lab') || 'The Lab'}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Notifications */}
|
|
||||||
<NotificationPanel />
|
|
||||||
|
|
||||||
{/* Settings */}
|
|
||||||
<a
|
|
||||||
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"
|
|
||||||
aria-label={t('settings.title')}
|
|
||||||
>
|
|
||||||
<Settings className="w-5 h-5" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Avatar + menu */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-center bg-center bg-no-repeat bg-cover rounded-full size-10 ring-2 ring-white dark:ring-slate-700 cursor-pointer shadow-sm hover:shadow-md transition-shadow bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground"
|
|
||||||
style={user?.image ? { backgroundImage: `url(${(user as any).image})` } : undefined}
|
|
||||||
>
|
|
||||||
{!user?.image && (
|
|
||||||
<span className="text-sm font-semibold">{initial}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<div className="flex items-center justify-start gap-2 p-2">
|
|
||||||
<div className="flex flex-col space-y-1 leading-none">
|
|
||||||
{user?.name && <p className="font-medium">{user.name}</p>}
|
|
||||||
{user?.email && <p className="w-[200px] truncate text-sm text-muted-foreground">{user.email}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
|
||||||
<a href="/settings/profile">
|
|
||||||
<User className="mr-2 h-4 w-4" />
|
|
||||||
<span>{t('settings.profile') }</span>
|
|
||||||
</a>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => signOut({ callbackUrl: '/' })}
|
|
||||||
className="cursor-pointer text-red-600 focus:text-red-600"
|
|
||||||
>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
<span>{t('auth.signOut') }</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { usePathname } from 'next/navigation'
|
|
||||||
import { LayoutDashboard, Users, Brain, Settings } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useLanguage } from '@/lib/i18n'
|
|
||||||
|
|
||||||
export interface AdminNavProps {
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavItem {
|
|
||||||
titleKey: string
|
|
||||||
href: string
|
|
||||||
icon: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
|
||||||
{
|
|
||||||
titleKey: 'admin.sidebar.dashboard',
|
|
||||||
href: '/admin',
|
|
||||||
icon: <LayoutDashboard className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: 'admin.sidebar.users',
|
|
||||||
href: '/admin/users',
|
|
||||||
icon: <Users className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: 'admin.sidebar.aiManagement',
|
|
||||||
href: '/admin/ai',
|
|
||||||
icon: <Brain className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: 'admin.sidebar.settings',
|
|
||||||
href: '/admin/settings',
|
|
||||||
icon: <Settings className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function AdminNav({ className }: AdminNavProps) {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const { t } = useLanguage()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className={cn('flex items-center gap-1', className)}>
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href + '/'))
|
|
||||||
|
|
||||||
return (
|
|
||||||
// <a> instead of <Link>: avoids Next.js RSC navigation transitions
|
|
||||||
// that trigger React Error #310 (React bug #33580) in production.
|
|
||||||
// Full-page reloads are acceptable for admin navigation.
|
|
||||||
<a
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 px-3 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
|
|
||||||
isActive
|
|
||||||
? 'border-primary text-primary'
|
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
<span>{t(item.titleKey)}</span>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { LanguageProvider } from '@/lib/i18n/LanguageProvider'
|
import { LanguageProvider } from '@/lib/i18n/LanguageProvider'
|
||||||
|
import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
|
||||||
|
import { QueryProvider } from '@/components/query-provider'
|
||||||
import type { Translations } from '@/lib/i18n/load-translations'
|
import type { Translations } from '@/lib/i18n/load-translations'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -16,11 +18,15 @@ export function AdminProvidersWrapper({
|
|||||||
initialTranslations,
|
initialTranslations,
|
||||||
}: AdminProvidersWrapperProps) {
|
}: AdminProvidersWrapperProps) {
|
||||||
return (
|
return (
|
||||||
<LanguageProvider
|
<QueryProvider>
|
||||||
initialLanguage={initialLanguage as any}
|
<NoteRefreshProvider>
|
||||||
initialTranslations={initialTranslations}
|
<LanguageProvider
|
||||||
>
|
initialLanguage={initialLanguage as any}
|
||||||
{children}
|
initialTranslations={initialTranslations}
|
||||||
</LanguageProvider>
|
>
|
||||||
|
{children}
|
||||||
|
</LanguageProvider>
|
||||||
|
</NoteRefreshProvider>
|
||||||
|
</QueryProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
219
memento-note/components/admin-sidebar.tsx
Normal file
219
memento-note/components/admin-sidebar.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { signOut, useSession } from 'next-auth/react'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Brain,
|
||||||
|
Settings,
|
||||||
|
StickyNote,
|
||||||
|
Shield,
|
||||||
|
ArrowLeft,
|
||||||
|
User,
|
||||||
|
LogOut,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { NotificationPanel } from '@/components/notification-panel'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
|
||||||
|
const ADMIN_NAV_ITEMS = [
|
||||||
|
{
|
||||||
|
titleKey: 'admin.sidebar.dashboard',
|
||||||
|
href: '/admin',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'admin.sidebar.users',
|
||||||
|
href: '/admin/users',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'admin.sidebar.aiManagement',
|
||||||
|
href: '/admin/ai',
|
||||||
|
icon: Brain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'admin.sidebar.settings',
|
||||||
|
href: '/admin/settings',
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function navItemIsActive(pathname: string | null, href: string): boolean {
|
||||||
|
if (!pathname) return false
|
||||||
|
if (href === '/admin') return pathname === '/admin'
|
||||||
|
if (href === '/admin/ai') return pathname === '/admin/ai' || pathname.startsWith('/admin/ai')
|
||||||
|
return pathname === href || pathname.startsWith(`${href}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Barre latérale administration — même vocabulaire visuel que {@link Sidebar}
|
||||||
|
* (fond bureau, panneau vitré, navigation arrondie). Liens en <a> pour
|
||||||
|
* éviter les transitions RSC qui déclenchent React #310 entre groupes de routes.
|
||||||
|
*/
|
||||||
|
export function AdminSidebar({ className }: { className?: string }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const user = session?.user
|
||||||
|
const initial = user?.name
|
||||||
|
? user.name.charAt(0).toUpperCase()
|
||||||
|
: user?.email?.[0]?.toUpperCase() ?? '?'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'flex h-full min-h-0 w-64 shrink-0 flex-col sm:w-72 lg:w-80',
|
||||||
|
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-border/30 dark:bg-sidebar/90',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Marque + retour app */}
|
||||||
|
<div className="flex flex-col gap-4 p-6 pb-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded-full outline-none ring-offset-background transition-shadow hover:ring-2 hover:ring-primary/30 focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
aria-label={t('sidebar.accountMenu') || 'Account menu'}
|
||||||
|
>
|
||||||
|
<div className="flex size-10 items-center justify-center overflow-hidden rounded-full border border-border bg-muted shadow-sm">
|
||||||
|
<Avatar className="size-10 ring-1 ring-border/60">
|
||||||
|
<AvatarImage src={(user as { image?: string } | undefined)?.image} alt="" />
|
||||||
|
<AvatarFallback className="bg-primary/10 font-memento-serif text-lg font-semibold text-primary">
|
||||||
|
{initial}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-52 bg-popover border-border">
|
||||||
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
|
<a href="/settings/profile" className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
{t('settings.profile')}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
|
<a href="/settings" className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
{t('nav.settings')}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
|
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
{t('auth.signOut')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-0 flex-1 items-center gap-2 rounded-xl px-2 py-1.5 transition-colors',
|
||||||
|
'hover:bg-white/40 dark:hover:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm">
|
||||||
|
<StickyNote className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-left">
|
||||||
|
<p className="truncate font-memento-serif text-[13px] font-semibold tracking-tight text-foreground">
|
||||||
|
MEMENTO
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t('admin.adminConsole')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-xl px-4 py-2.5 text-[13px] font-medium transition-all',
|
||||||
|
'text-muted-foreground hover:bg-white/40 hover:text-foreground dark:hover:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full border border-border bg-white/60 dark:bg-white/10">
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</div>
|
||||||
|
<span>{t('admin.backToApp')}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary dark:bg-primary/20">
|
||||||
|
<Shield className="size-3.5 shrink-0" />
|
||||||
|
<span>{t('admin.title')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-4 custom-scrollbar">
|
||||||
|
<p className="mb-3 px-4 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{t('admin.navSection')}
|
||||||
|
</p>
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{ADMIN_NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const active = navItemIsActive(pathname, item.href)
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-xl px-4 py-3 transition-all duration-300',
|
||||||
|
active
|
||||||
|
? 'memento-active-nav text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-white/40 hover:text-foreground dark:hover:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex size-8 shrink-0 items-center justify-center rounded-full border transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-foreground bg-foreground text-background'
|
||||||
|
: 'border-border bg-white/60 dark:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[13px] font-medium">{t(item.titleKey)}</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pied */}
|
||||||
|
<div className="space-y-1 border-t border-border p-5 pt-4">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2">
|
||||||
|
<NotificationPanel />
|
||||||
|
<span className="text-[13px] font-medium text-muted-foreground">
|
||||||
|
{t('notification.notifications')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/settings"
|
||||||
|
className="flex items-center gap-3 rounded-lg px-4 py-2 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
<span>{t('nav.settings')}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user