feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export - Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG) - Add document Q&A overlay with streaming chat and PDF preview - Add note attachments UI with status polling, grid layout, and auto-scroll - Add task extraction AI tool and agent executor improvements - Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings - Fix brainstorm 'Create Note' button: add success toast and redirect to created note - Fix memory echo notification infinite polling - Fix chat route to always include document_search tool - Add brainstorm i18n keys across all 14 locales - Add socket server for real-time brainstorm collaboration - Add hierarchical notebook selector and organize notebook dialog improvements - Add sidebar brainstorm section with session management - Update prisma schema with brainstorm tables, attachments, and document chunks
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
"Bash(npm run *)",
|
||||
"Bash(curl -s http://localhost:3000)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/)",
|
||||
"Bash(kill 3309513)"
|
||||
"Bash(kill 3309513)",
|
||||
"Bash(python3 /home/devparsa/dev/Momento/_bmad/scripts/resolve_customization.py --skill /home/devparsa/dev/Momento/.claude/skills/bmad-create-architecture --key workflow)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
8
.cursor/hooks/state/continual-learning.json
Normal file
8
.cursor/hooks/state/continual-learning.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"lastRunAtMs": 0,
|
||||
"turnsSinceLastRun": 8,
|
||||
"lastTranscriptMtimeMs": null,
|
||||
"lastProcessedGenerationId": "d40421fd-8dd1-472a-9807-a1c4f939b552",
|
||||
"trialStartedAtMs": null
|
||||
}
|
||||
9
architectural-grid11/.env.example
Normal file
9
architectural-grid11/.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-grid11/.gitignore
vendored
Normal file
8
architectural-grid11/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
24
architectural-grid11/BRAINSTORM_PROMPT.md
Normal file
24
architectural-grid11/BRAINSTORM_PROMPT.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# IA Agent Coordination Prompt: Brainstorm Wave Integration
|
||||
|
||||
## Context
|
||||
You are tasked with continuing the development of the "Architectural Grid" application. The core feature "Wave Brainstorming" has been partially implemented with a full-stack architecture (Express + React).
|
||||
|
||||
## Current State
|
||||
- **Backend (`server.ts`)**: Implements session management, idea generation via Gemini, and expansion logic. Stores data in memory.
|
||||
- **Frontend (`BrainstormView.tsx`)**: Manages the life cycle of a brainstorm. Integrates with a Radial D3 Canvas.
|
||||
- **Visuals (`WaveCanvas.tsx`)**: Implements a radial force-directed graph with state-aware styling (dismissed/converted).
|
||||
- **Navigation**: "Brainstorm Wave" is accessible from the Sidebar. A quick entry point exists from Note Detail view.
|
||||
|
||||
## Your Task: Sidebar & Navigation Cleanup
|
||||
1. **Source Code Review**: Read `src/components/Sidebar.tsx`, `src/App.tsx`, and `server.ts` to understand how views are toggled.
|
||||
2. **Sidebar Links**: Ensure "Brainstorm Wave", "Semantic Network", and "Temporal Forecast" are correctly grouped and labeled in the Sidebar under a "Creative & AI" section.
|
||||
3. **Agent View Sidebar**: The user specifically requested these links to be also accessible from the "Sidebar of the Agent view". Review `src/components/AgentsView.tsx` and ensure it has consistent navigation or deep links to these advanced features.
|
||||
4. **Semantic Network & Temporal Forecast**: These views are currently placeholders. Ensure the routing and sidebar active state detection work correctly for them.
|
||||
|
||||
## Technical Requirements
|
||||
- Maintain consistency with the **Tailwind** architectural design (concrete, paper, blueprint tokens).
|
||||
- Use **Lucide-React** icons (`Wind` for Brainstorm, `Share2` for Semantic Network, `Clock` for Temporal).
|
||||
- Ensure transitions between views are smooth using `motion/react`.
|
||||
|
||||
---
|
||||
*Copy and paste this into the next AI Agent session to ensure full context transfer.*
|
||||
20
architectural-grid11/README.md
Normal file
20
architectural-grid11/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-grid11/index.html
Normal file
13
architectural-grid11/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-grid11/metadata.json
Normal file
6
architectural-grid11/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Architectural Grid",
|
||||
"description": "A minimalist notebook for architectural research and conceptual sketches, featuring a Wave Brainstorming radial canvas for AI-powered idea exploration.",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": []
|
||||
}
|
||||
5497
architectural-grid11/package-lock.json
generated
Normal file
5497
architectural-grid11/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
architectural-grid11/package.json
Normal file
39
architectural-grid11/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx server.ts",
|
||||
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
|
||||
"start": "node dist/server.cjs",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.29.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"d3": "^7.9.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "^6.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
126
architectural-grid11/server.ts
Normal file
126
architectural-grid11/server.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { createServer as createViteServer } from "vite";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface BrainstormIdea {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
waveNumber: number;
|
||||
title: string;
|
||||
description: string;
|
||||
connectionToSeed: string;
|
||||
noveltyScore: number;
|
||||
parentIdeaId?: string;
|
||||
convertedToNoteId?: string;
|
||||
status: 'active' | 'dismissed' | 'converted';
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface BrainstormSession {
|
||||
id: string;
|
||||
seedIdea: string;
|
||||
sourceNoteId?: string;
|
||||
contextNoteIds?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// In-memory store (Simulating Postgres for now)
|
||||
const sessions: BrainstormSession[] = [];
|
||||
const ideas: BrainstormIdea[] = [];
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// API Routes
|
||||
app.get("/api/health", (req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// 1. Create session
|
||||
app.post("/api/brainstorm/sessions", (req, res) => {
|
||||
const { seedIdea, sourceNoteId, contextNoteIds } = req.body;
|
||||
|
||||
const session: BrainstormSession = {
|
||||
id: uuidv4(),
|
||||
seedIdea,
|
||||
sourceNoteId,
|
||||
contextNoteIds,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
sessions.unshift(session);
|
||||
res.json(session);
|
||||
});
|
||||
|
||||
// 2. Add ideas to session
|
||||
app.post("/api/brainstorm/:sessionId/ideas", (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const { ideas: newIdeasData } = req.body;
|
||||
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
if (!session) return res.status(404).json({ error: "Session not found" });
|
||||
|
||||
const newIdeas = newIdeasData.map((item: any) => ({
|
||||
id: item.id || uuidv4(),
|
||||
sessionId,
|
||||
waveNumber: item.waveNumber,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
connectionToSeed: item.connectionToSeed,
|
||||
noveltyScore: item.noveltyScore,
|
||||
parentIdeaId: item.parentIdeaId,
|
||||
status: 'active'
|
||||
}));
|
||||
|
||||
newIdeas.forEach((i: any) => ideas.push(i));
|
||||
res.json(newIdeas);
|
||||
});
|
||||
|
||||
// 3. Get all sessions
|
||||
app.get("/api/brainstorm/sessions", (req, res) => {
|
||||
res.json(sessions);
|
||||
});
|
||||
|
||||
// 4. Get session with ideas
|
||||
app.get("/api/brainstorm/:sessionId", (req, res) => {
|
||||
const session = sessions.find(s => s.id === req.params.sessionId);
|
||||
if (!session) return res.status(404).json({ error: "Session not found" });
|
||||
const sessionIdeas = ideas.filter(i => i.sessionId === session.id);
|
||||
res.json({ session, ideas: sessionIdeas });
|
||||
});
|
||||
|
||||
// 5. Update idea (position, status)
|
||||
app.patch("/api/brainstorm/ideas/:ideaId", (req, res) => {
|
||||
const index = ideas.findIndex(i => i.id === req.params.ideaId);
|
||||
if (index === -1) return res.status(404).json({ error: "Idea not found" });
|
||||
|
||||
ideas[index] = { ...ideas[index], ...req.body };
|
||||
res.json(ideas[index]);
|
||||
});
|
||||
|
||||
// Vite middleware for development
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const vite = await createViteServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "spa",
|
||||
});
|
||||
app.use(vite.middlewares);
|
||||
} else {
|
||||
const distPath = path.join(process.cwd(), 'dist');
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
603
architectural-grid11/src/App.tsx
Normal file
603
architectural-grid11/src/App.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
// Components
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { NotebooksView } from './components/NotebooksView';
|
||||
import { AgentsView } from './components/AgentsView';
|
||||
import { SettingsView } from './components/SettingsView';
|
||||
import { TrashView } from './components/TrashView';
|
||||
import { BrainstormView } from './components/BrainstormView/BrainstormView';
|
||||
import { InsightsView } from './components/InsightsView';
|
||||
import { TemporalView } from './components/TemporalView';
|
||||
import { AISidebar } from './components/AISidebar';
|
||||
import { SlashMenu } from './components/SlashMenu';
|
||||
|
||||
// Data & Types
|
||||
import { CARNETS, ALL_NOTES } from './constants';
|
||||
import { NavigationView, SettingsTab, AITab, AITone, Carnet, Note, BrainstormIdea, NoteAccessLog } from './types';
|
||||
|
||||
export default function App() {
|
||||
const [activeView, setActiveView] = useState<NavigationView>('notebooks');
|
||||
const [activeSettingsTab, setActiveSettingsTab] = useState<SettingsTab>('general');
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [carnets, setCarnets] = useState<Carnet[]>(CARNETS);
|
||||
const [notes, setNotes] = useState<Note[]>(ALL_NOTES);
|
||||
const [accessLogs, setAccessLogs] = useState<NoteAccessLog[]>([
|
||||
// Note n1: 14-day cycle
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 70 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 56 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 42 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
|
||||
// Note n2: 7-day cycle
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
|
||||
// Note n3: 3-day cycle (frequent check)
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 12 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 9 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
]);
|
||||
|
||||
const logNoteAccess = (noteId: string, action: 'view' | 'edit' | 'search_hit' = 'view') => {
|
||||
const newLog: NoteAccessLog = {
|
||||
noteId,
|
||||
accessedAt: new Date().toISOString(),
|
||||
action
|
||||
};
|
||||
setAccessLogs(prev => [...prev, newLog]);
|
||||
};
|
||||
|
||||
const [activeCarnetId, setActiveCarnetId] = useState('4');
|
||||
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
|
||||
const [brainstormSeed, setBrainstormSeed] = useState<string | null>(null);
|
||||
|
||||
const handleBrainstormNote = (note: Note) => {
|
||||
setActiveView('brainstorm');
|
||||
// We'll use a small delay or a ref to pass this to BrainstormView if needed,
|
||||
// but better to just share state or use a CustomEvent
|
||||
window.dispatchEvent(new CustomEvent('start-brainstorm', {
|
||||
detail: { seed: note.title, sourceNoteId: note.id }
|
||||
}));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeNoteId) {
|
||||
logNoteAccess(activeNoteId);
|
||||
}
|
||||
}, [activeNoteId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleSwitchView = (e: any) => {
|
||||
if (e.detail) {
|
||||
setActiveView(e.detail as NavigationView);
|
||||
}
|
||||
};
|
||||
window.addEventListener('switch-view', handleSwitchView);
|
||||
return () => window.removeEventListener('switch-view', handleSwitchView);
|
||||
}, []);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const [isAISidebarOpen, setIsAISidebarOpen] = useState(false);
|
||||
const [aiTab, setAiTab] = useState<AITab>('discussion');
|
||||
const [selectedTone, setSelectedTone] = useState<AITone>('Professional');
|
||||
|
||||
// Modal States
|
||||
const [showNewCarnetModal, setShowNewCarnetModal] = useState<{ isOpen: boolean; parentId?: string; isRenaming?: boolean; carnetId?: string }>({ isOpen: false });
|
||||
const [showNewNoteModal, setShowNewNoteModal] = useState(false);
|
||||
const [slashMenu, setSlashMenu] = useState<{ isOpen: boolean; top: number; left: number } | null>(null);
|
||||
|
||||
// Form States
|
||||
const [newCarnetName, setNewCarnetName] = useState('');
|
||||
const [newNoteTitle, setNewNoteTitle] = useState('');
|
||||
const [newNoteContent, setNewNoteContent] = useState('');
|
||||
|
||||
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === '/') {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
setSlashMenu({
|
||||
isOpen: true,
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const togglePin = (noteId: string) => {
|
||||
setNotes(notes.map(n => n.id === noteId ? { ...n, isPinned: !n.isPinned } : n));
|
||||
};
|
||||
|
||||
const filteredNotes = useMemo(() => {
|
||||
let result = notes.filter(n => n.carnetId === activeCarnetId && !n.isDeleted);
|
||||
|
||||
if (selectedTagIds.length > 0) {
|
||||
result = result.filter(note =>
|
||||
selectedTagIds.every(tagId => note.tags?.some(tag => tag.id === tagId))
|
||||
);
|
||||
}
|
||||
|
||||
return [...result].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [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;
|
||||
|
||||
if (showNewCarnetModal.isRenaming && showNewCarnetModal.carnetId) {
|
||||
setCarnets(carnets.map(c => c.id === showNewCarnetModal.carnetId ? { ...c, name: newCarnetName, initial: newCarnetName.charAt(0).toUpperCase() } : c));
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setNewCarnetName('');
|
||||
return;
|
||||
}
|
||||
|
||||
const newCarnet: Carnet = {
|
||||
id: Date.now().toString(),
|
||||
name: newCarnetName,
|
||||
initial: newCarnetName.charAt(0).toUpperCase(),
|
||||
type: 'Project',
|
||||
parentId: showNewCarnetModal.parentId
|
||||
};
|
||||
|
||||
setCarnets([...carnets, newCarnet]);
|
||||
setNewCarnetName('');
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setActiveCarnetId(newCarnet.id);
|
||||
};
|
||||
|
||||
const handleDeleteCarnet = (id: string) => {
|
||||
if (window.confirm('Déplacer ce carnet et ses sous-carnets vers la corbeille ?')) {
|
||||
const idsToDelete = new Set<string>([id]);
|
||||
|
||||
const addChildren = (parentId: string) => {
|
||||
carnets.forEach(c => {
|
||||
if (c.parentId === parentId) {
|
||||
idsToDelete.add(c.id);
|
||||
addChildren(c.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
addChildren(id);
|
||||
|
||||
const deletedAt = new Date().toISOString();
|
||||
setCarnets(carnets.map(c => idsToDelete.has(c.id) ? { ...c, isDeleted: true, deletedAt } : c));
|
||||
setNotes(notes.map(n => idsToDelete.has(n.carnetId) ? { ...n, isDeleted: true, deletedAt } : n));
|
||||
|
||||
if (idsToDelete.has(activeCarnetId)) {
|
||||
setActiveCarnetId('1');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNote = (id: string) => {
|
||||
const deletedAt = new Date().toISOString();
|
||||
setNotes(notes.map(n => n.id === id ? { ...n, isDeleted: true, deletedAt } : n));
|
||||
if (activeNoteId === id) setActiveNoteId(null);
|
||||
};
|
||||
|
||||
const handleRestoreCarnet = (id: string) => {
|
||||
setCarnets(carnets.map(c => c.id === id ? { ...c, isDeleted: false, deletedAt: undefined } : c));
|
||||
// Optionally restore linked notes too? User might expect that.
|
||||
setNotes(notes.map(n => n.carnetId === id ? { ...n, isDeleted: false, deletedAt: undefined } : n));
|
||||
};
|
||||
|
||||
const handleRestoreNote = (id: string) => {
|
||||
setNotes(notes.map(n => n.id === id ? { ...n, isDeleted: false, deletedAt: undefined } : n));
|
||||
};
|
||||
|
||||
const handlePermanentDeleteNote = (id: string) => {
|
||||
setNotes(notes.filter(n => n.id !== id));
|
||||
};
|
||||
|
||||
const handlePermanentDeleteCarnet = (id: string) => {
|
||||
const idsToDelete = new Set<string>([id]);
|
||||
const addChildren = (parentId: string) => {
|
||||
carnets.forEach(c => {
|
||||
if (c.parentId === parentId) {
|
||||
idsToDelete.add(c.id);
|
||||
addChildren(c.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
addChildren(id);
|
||||
setCarnets(carnets.filter(c => !idsToDelete.has(c.id)));
|
||||
setNotes(notes.filter(n => !idsToDelete.has(n.carnetId)));
|
||||
};
|
||||
|
||||
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',
|
||||
tags: []
|
||||
};
|
||||
|
||||
setNotes([newNote, ...notes]);
|
||||
setNewNoteTitle('');
|
||||
setNewNoteContent('');
|
||||
setShowNewNoteModal(false);
|
||||
setActiveNoteId(newNote.id);
|
||||
};
|
||||
|
||||
const handleConvertIdeaToNote = (idea: BrainstormIdea) => {
|
||||
const newNote: Note = {
|
||||
id: `n-gen-${Date.now()}`,
|
||||
carnetId: activeCarnetId,
|
||||
title: idea.title,
|
||||
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
|
||||
content: `${idea.description}\n\n---\n**Connection to seed:** ${idea.connectionToSeed}\n**Novelty Score:** ${idea.noveltyScore}/10`,
|
||||
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't-ai', label: 'AI Generated', type: 'ai' }]
|
||||
};
|
||||
|
||||
setNotes([newNote, ...notes]);
|
||||
setActiveView('notebooks');
|
||||
setActiveNoteId(newNote.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`h-screen flex bg-paper transition-colors duration-500 overflow-hidden font-sans ${isDarkMode ? 'dark' : ''}`}>
|
||||
<Sidebar
|
||||
activeView={activeView}
|
||||
isDarkMode={isDarkMode}
|
||||
setIsDarkMode={setIsDarkMode}
|
||||
setActiveView={setActiveView}
|
||||
carnets={carnets}
|
||||
notes={notes}
|
||||
activeCarnetId={activeCarnetId}
|
||||
activeNoteId={activeNoteId}
|
||||
setActiveCarnetId={setActiveCarnetId}
|
||||
setActiveNoteId={setActiveNoteId}
|
||||
setShowNewCarnetModal={(show, parentId, isRenaming, carnetId) => {
|
||||
setShowNewCarnetModal({ isOpen: show, parentId, isRenaming, carnetId });
|
||||
if (isRenaming && carnetId) {
|
||||
const carnet = carnets.find(c => c.id === carnetId);
|
||||
if (carnet) setNewCarnetName(carnet.name);
|
||||
} else {
|
||||
setNewCarnetName('');
|
||||
}
|
||||
}}
|
||||
onDeleteCarnet={handleDeleteCarnet}
|
||||
onMoveCarnet={(draggedId, targetId) => {
|
||||
if (draggedId === targetId) return;
|
||||
|
||||
// Basic circular check
|
||||
const isDescendant = (parentId: string, potentialChildId: string): boolean => {
|
||||
const childIds = carnets.filter(c => c.parentId === parentId).map(c => c.id);
|
||||
if (childIds.includes(potentialChildId)) return true;
|
||||
return childIds.some(id => isDescendant(id, potentialChildId));
|
||||
};
|
||||
|
||||
if (targetId && isDescendant(draggedId, targetId)) {
|
||||
console.warn("Cannot move a notebook inside its own descendant");
|
||||
return;
|
||||
}
|
||||
|
||||
setCarnets(prev => prev.map(c => c.id === draggedId ? { ...c, parentId: targetId } : c));
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="flex-1 relative overflow-hidden flex bg-paper dark:bg-dark-paper transition-colors duration-500">
|
||||
<AnimatePresence mode="wait">
|
||||
{(activeView === 'notebooks' || activeView === 'shared' || activeView === 'reminders') && (
|
||||
<motion.div
|
||||
key={activeView}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<NotebooksView
|
||||
activeNoteId={activeNoteId}
|
||||
activeCarnet={activeCarnet}
|
||||
filteredNotes={filteredNotes}
|
||||
activeNote={activeNote}
|
||||
setActiveNoteId={setActiveNoteId}
|
||||
togglePin={togglePin}
|
||||
setShowNewNoteModal={setShowNewNoteModal}
|
||||
isAISidebarOpen={isAISidebarOpen}
|
||||
setIsAISidebarOpen={setIsAISidebarOpen}
|
||||
selectedTagIds={selectedTagIds}
|
||||
setSelectedTagIds={setSelectedTagIds}
|
||||
allNotes={notes}
|
||||
activeCarnetId={activeCarnetId}
|
||||
setShowNewCarnetModal={(show, parentId, isRenaming, carnetId) => setShowNewCarnetModal({ isOpen: show, parentId, isRenaming, carnetId })}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
onBrainstormNote={handleBrainstormNote}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'trash' && (
|
||||
<motion.div
|
||||
key="trash"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TrashView
|
||||
deletedNotes={notes.filter(n => n.isDeleted)}
|
||||
deletedCarnets={carnets.filter(c => c.isDeleted)}
|
||||
onRestoreNote={handleRestoreNote}
|
||||
onRestoreCarnet={handleRestoreCarnet}
|
||||
onPermanentDeleteNote={handlePermanentDeleteNote}
|
||||
onPermanentDeleteCarnet={handlePermanentDeleteCarnet}
|
||||
onEmptyTrash={() => {
|
||||
setNotes(notes.filter(n => !n.isDeleted));
|
||||
setCarnets(carnets.filter(c => !c.isDeleted));
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'agents' && (
|
||||
<motion.div
|
||||
key="agents"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<AgentsView
|
||||
selectedAgentId={selectedAgentId}
|
||||
setSelectedAgentId={setSelectedAgentId}
|
||||
carnets={carnets}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'settings' && (
|
||||
<motion.div
|
||||
key="settings"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<SettingsView
|
||||
activeSettingsTab={activeSettingsTab}
|
||||
setActiveSettingsTab={setActiveSettingsTab}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'brainstorm' && (
|
||||
<motion.div
|
||||
key="brainstorm"
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<BrainstormView
|
||||
notes={notes}
|
||||
onConvertNote={handleConvertIdeaToNote}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'insights' && (
|
||||
<motion.div
|
||||
key="insights"
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<InsightsView
|
||||
notes={notes}
|
||||
onUpdateNotes={setNotes}
|
||||
onNoteSelect={(noteId) => {
|
||||
setActiveView('notebooks');
|
||||
setActiveNoteId(noteId);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'temporal' && (
|
||||
<motion.div
|
||||
key="temporal"
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TemporalView
|
||||
notes={notes}
|
||||
accessLogs={accessLogs}
|
||||
onNoteSelect={(noteId) => {
|
||||
setActiveView('notebooks');
|
||||
setActiveNoteId(noteId);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AISidebar
|
||||
isOpen={isAISidebarOpen}
|
||||
setIsOpen={setIsAISidebarOpen}
|
||||
activeNote={activeNote}
|
||||
aiTab={aiTab}
|
||||
setAiTab={setAiTab}
|
||||
selectedTone={selectedTone}
|
||||
setSelectedTone={setSelectedTone}
|
||||
carnets={carnets}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
<AnimatePresence>
|
||||
{showNewCarnetModal.isOpen && (
|
||||
<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({ isOpen: 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 dark:bg-dark-paper border border-border shadow-2xl rounded-2xl p-8"
|
||||
>
|
||||
<h3 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink mb-2">
|
||||
{showNewCarnetModal.isRenaming ? 'Rename Carnet' : (showNewCarnetModal.parentId ? 'Create Sub-Carnet' : 'Create New Carnet')}
|
||||
</h3>
|
||||
{showNewCarnetModal.parentId && !showNewCarnetModal.isRenaming && (
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest font-bold mb-6">
|
||||
Inside: {carnets.find(c => c.id === showNewCarnetModal.parentId)?.name}
|
||||
</p>
|
||||
)}
|
||||
<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 dark:bg-[#2A2A2A] border border-border rounded-lg px-4 py-3 outline-none focus:border-ink transition-colors font-serif italic text-lg text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setNewCarnetName('');
|
||||
}}
|
||||
className="flex-1 py-3 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 dark:hover:bg-white/5 transition-colors text-ink dark:text-dark-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-3 bg-ink dark:bg-ochre text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{showNewCarnetModal.isRenaming ? 'Rename' : '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 dark:bg-dark-paper border border-border shadow-2xl rounded-2xl p-10"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{slashMenu?.isOpen && (
|
||||
<SlashMenu
|
||||
position={{ top: slashMenu.top, left: slashMenu.left }}
|
||||
onSelect={(type) => { console.log(type); setSlashMenu(null); }}
|
||||
onClose={() => setSlashMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<h3 className="text-3xl font-serif font-medium text-ink dark:text-dark-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 dark:bg-[#2A2A2A] border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-serif text-2xl text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</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)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
placeholder="Describe the spatial logic, materiality, and light interactions... (Type '/' for commands)"
|
||||
rows={6}
|
||||
className="w-full bg-white dark:bg-[#2A2A2A] border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-light leading-relaxed resize-none text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</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 dark:hover:bg-white/5 transition-colors text-ink dark:text-dark-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-4 bg-ink dark:bg-ochre text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Save Note
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
466
architectural-grid11/src/components/AISidebar.tsx
Normal file
466
architectural-grid11/src/components/AISidebar.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
FileCode,
|
||||
Globe,
|
||||
Send,
|
||||
Scissors,
|
||||
Zap,
|
||||
Languages,
|
||||
Layout,
|
||||
ArrowRightLeft,
|
||||
BookOpen,
|
||||
History,
|
||||
Target,
|
||||
Network,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { AITab, AITone, Note, Carnet } from '../types';
|
||||
import { HierarchicalCarnetSelector } from './HierarchicalCarnetSelector';
|
||||
|
||||
interface AISidebarProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
activeNote: Note | undefined;
|
||||
aiTab: AITab;
|
||||
setAiTab: (tab: AITab) => void;
|
||||
selectedTone: AITone;
|
||||
setSelectedTone: (tone: AITone) => void;
|
||||
carnets: Carnet[];
|
||||
}
|
||||
|
||||
export const AISidebar: React.FC<AISidebarProps> = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
activeNote,
|
||||
aiTab,
|
||||
setAiTab,
|
||||
selectedTone,
|
||||
setSelectedTone,
|
||||
carnets
|
||||
}) => {
|
||||
const [selectedContextId, setSelectedContextId] = React.useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<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"
|
||||
>
|
||||
<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-ochre" />
|
||||
IA Assistant
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(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>
|
||||
|
||||
<div className="flex border-b border-border px-2">
|
||||
{(['discussion', 'actions', 'explore', 'resources'] as AITab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setAiTab(tab)}
|
||||
className={`flex-1 py-3 text-[10px] uppercase tracking-[0.2em] font-bold transition-all relative
|
||||
${aiTab === tab ? 'text-manganese' : '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-ochre"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
<AnimatePresence mode="wait">
|
||||
{aiTab === 'explore' && (
|
||||
<motion.div
|
||||
key="explore"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<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">Intelligence Modules</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
// These will be handled in App.tsx by observing activeView
|
||||
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'brainstorm' }));
|
||||
}}
|
||||
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-ochre/30 transition-all text-left overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Zap size={60} className="text-ochre" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-ochre/10 rounded-xl text-ochre group-hover:bg-ochre group-hover:text-white transition-colors">
|
||||
<Zap size={20} fill="currentColor" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">Brainstorm Wave</h5>
|
||||
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Unfold dimensions of thought</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'insights' }));
|
||||
}}
|
||||
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-indigo-500/30 transition-all text-left overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Network size={60} className="text-indigo-500" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-indigo-500/10 rounded-xl text-indigo-500 group-hover:bg-indigo-500 group-hover:text-white transition-colors">
|
||||
<Network size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">Semantic Network</h5>
|
||||
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Detect clusters and bridges</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'temporal' }));
|
||||
}}
|
||||
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-rose-500/30 transition-all text-left overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Clock size={60} className="text-rose-500" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-rose-500/10 rounded-xl text-rose-500 group-hover:bg-rose-500 group-hover:text-white transition-colors">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">Temporal Forecast</h5>
|
||||
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Predict relevance recurrence</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-50 dark:bg-white/5 border border-dashed border-border mt-6">
|
||||
<p className="text-[10px] text-muted-ink leading-relaxed font-medium italic text-center">
|
||||
Ces modules utilisent les embeddings du modèle Gemini pour analyser graphiquement vos pensées.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{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">Source du Contexte</label>
|
||||
<div className="space-y-3">
|
||||
<div className="w-full p-3 bg-glass border border-border rounded-lg text-xs flex items-center justify-between cursor-default backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode size={14} className="text-blueprint" />
|
||||
<span className="font-medium text-ink">Note Active</span>
|
||||
</div>
|
||||
<div className="text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter italic">Auto</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">+ Carnet</span>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<HierarchicalCarnetSelector
|
||||
carnets={carnets}
|
||||
selectedId={selectedContextId}
|
||||
onSelect={setSelectedContextId}
|
||||
placeholder="Inclure un carnet..."
|
||||
className="w-full"
|
||||
/>
|
||||
</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-manganese text-paper border-manganese shadow-lg shadow-manganese/10' : 'bg-glass border-border text-muted-ink hover:border-ink/20'}`}
|
||||
>
|
||||
{tone.toUpperCase().substring(0, 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">
|
||||
<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', color: 'ochre' },
|
||||
{ icon: <Scissors size={14} />, label: 'Raccourcir', color: 'rust' },
|
||||
{ icon: <Zap size={14} />, label: 'Améliorer', color: 'sage' },
|
||||
{ icon: <Languages size={14} />, label: 'Traduire', color: 'slate' },
|
||||
].map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="flex flex-col items-center gap-3 p-4 bg-glass border border-border rounded-xl transition-all group hover:border-ink/20"
|
||||
>
|
||||
<div className={`p-2 rounded-lg bg-slate-50 dark:bg-white/10 transition-colors group-hover:bg-manganese group-hover:text-paper shadow-sm text-ink/60`}>
|
||||
{action.icon}
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-ink/80 uppercase tracking-widest">{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="col-span-2 flex items-center justify-center gap-3 py-3 px-4 bg-glass border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-slate-50 dark:hover:bg-white/10 transition-colors hover:border-ink/20 uppercase tracking-widest">
|
||||
<FileCode size={14} className="text-muted-ink" />
|
||||
Convertir en Markdown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-blueprint/30 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-blueprint" />
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-blueprint">
|
||||
<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-[0.2em] font-bold text-muted-ink/60 px-1">Thème</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-blueprint/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-[0.2em] font-bold text-muted-ink/60 px-1">Style</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-blueprint/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-blueprint text-paper rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-blueprint/20 uppercase tracking-[0.2em]">
|
||||
Générer
|
||||
<ArrowRightLeft size={14} className="opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-sage/30 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-sage" />
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-sage">
|
||||
<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-[0.2em] font-bold text-muted-ink/60 px-1">Type</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-sage/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-[0.2em] font-bold text-muted-ink/60 px-1">Style</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-sage/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-sage text-paper rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-sage/20 uppercase tracking-[0.2em]">
|
||||
Tracer
|
||||
<ArrowRightLeft size={14} className="opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-glass border border-border rounded-lg pl-3 pr-10 py-3 text-xs outline-none focus:border-blueprint 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-glass border border-border rounded-lg p-4 text-xs outline-none focus:border-blueprint 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-sage/10 border-sage/50 ring-1 ring-sage/10' : 'bg-white border-border hover:bg-slate-50'}`}>
|
||||
<span className={`text-[11px] font-bold ${mode.id === 'append' ? 'text-sage' : '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-blueprint text-white rounded-xl text-sm font-bold flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-blueprint/20">
|
||||
<Sparkles size={18} />
|
||||
Générer l'aperçu
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<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-glass backdrop-blur-sm border border-border rounded-2xl p-4 pr-12 text-sm outline-none focus:border-blueprint 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-blueprint text-white rounded-lg transition-transform hover:scale-105 active:scale-95 shadow-lg shadow-blueprint/10">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
325
architectural-grid11/src/components/AgentsView.tsx
Normal file
325
architectural-grid11/src/components/AgentsView.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
Activity,
|
||||
Trash2,
|
||||
Edit3,
|
||||
Play,
|
||||
Eye,
|
||||
Microscope,
|
||||
Globe,
|
||||
Layers,
|
||||
Zap,
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Info,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Carnet, Note } from '../types';
|
||||
import { HierarchicalCarnetSelector } from './HierarchicalCarnetSelector';
|
||||
|
||||
interface AgentsViewProps {
|
||||
selectedAgentId: string | null;
|
||||
setSelectedAgentId: (id: string | null) => void;
|
||||
carnets: Carnet[];
|
||||
}
|
||||
|
||||
export const AgentsView: React.FC<AgentsViewProps> = ({
|
||||
selectedAgentId,
|
||||
setSelectedAgentId,
|
||||
carnets
|
||||
}) => {
|
||||
const [selectedCarnetForAgent, setSelectedCarnetForAgent] = React.useState<string | null>('4');
|
||||
const [agentType, setAgentType] = React.useState<'Surveillant' | 'Personnalisé' | 'Slides' | 'Diagramme'>('Diagramme');
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-y-auto custom-scrollbar bg-[#F9F8F6] dark:bg-dark-paper space-y-12">
|
||||
{!selectedAgentId ? (
|
||||
<>
|
||||
<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-end">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink">Mes Agents</h1>
|
||||
<p className="text-sm text-muted-ink font-light">Automatisez vos tâches de veille et de recherche.</p>
|
||||
</div>
|
||||
<button className="px-6 py-2.5 bg-ink text-paper text-sm font-medium rounded-xl hover:opacity-90 transition-all flex items-center gap-3 shadow-lg shadow-ink/10">
|
||||
<Plus size={18} />
|
||||
Nouvel Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 border-b border-ink/5 pt-4">
|
||||
{['Tous', 'Veilleur', 'Chercheur', 'Surveillant', 'Personnalisé'].map((tag, i) => (
|
||||
<button key={i} className={`pb-4 text-xs font-bold uppercase tracking-widest transition-all relative ${i === 0 ? 'text-ink' : 'text-muted-ink hover:text-ink/60'}`}>
|
||||
{tag}
|
||||
{i === 0 && <motion.div layoutId="activeAgentTag" className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-12 flex-1 pb-20 space-y-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ id: 'a1', icon: <Eye size={20} className="text-amber-600" />, title: 'Surveillant de Notes', status: 'Réussi', type: 'SURVEILLANT', meta: 'Hebdomadaire • 6 exéc.', desc: 'Analyse les notes récentes d’un carnet et suggère des compléments, références et liens.' },
|
||||
{ id: 'a2', icon: <Microscope size={20} className="text-indigo-600" />, title: 'Chercheur de Sujet', status: 'Réussi', type: 'CHERCHEUR', meta: 'Hebdomadaire • 14 exéc.', desc: 'Recherche des informations approfondies sur les derniers modèles de Deepseek et voir l’avis des utilisateurs.' },
|
||||
{ id: 'a3', icon: <Globe size={20} className="text-emerald-600" />, title: 'Veille IA', status: 'Réussi', type: 'VEILLEUR', meta: 'Quotidien • 20 exéc.', desc: 'Scrape les flux RSS de 6 sites IA (The Verge, TechCrunch...) et génère un résumé.' },
|
||||
].map((agent, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
className="bg-white dark:bg-white/5 border border-border rounded-2xl p-6 space-y-6 hover:border-ink/20 transition-all group cursor-pointer shadow-sm relative overflow-hidden"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-slate-50 dark:bg-white/10 rounded-xl group-hover:bg-ink group-hover:text-paper transition-all">
|
||||
{agent.icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-ink">{agent.title}</h4>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-ink opacity-60">{agent.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-8 h-4 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-emerald-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-ink leading-relaxed line-clamp-3">
|
||||
{agent.desc}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-ink font-medium">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1"><Clock size={10} /> {agent.meta.split('•')[0]}</span>
|
||||
<span>{agent.meta.split('•')[1]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-ink font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="uppercase tracking-tight">Prochaine exécution</span>
|
||||
<span className="text-ink">Hebdomadaire</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="uppercase tracking-tight">Dernier statut</span>
|
||||
<span className="text-emerald-600 flex items-center gap-1"><Activity size={8} /> {agent.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-t border-border pt-4">
|
||||
<button className="py-2 border border-border rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 flex items-center justify-center transition-colors text-muted-ink hover:text-ink"><Edit3 size={14} /> <span className="ml-2 text-[10px] font-bold uppercase">Modifier</span></button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
className="py-2 border border-border rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 flex items-center justify-center transition-colors text-muted-ink hover:text-ink"
|
||||
>
|
||||
<Play size={14} className="fill-current" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
className="py-2 border border-border rounded-lg hover:bg-rose-50 hover:text-rose-600 hover:border-rose-100 flex items-center justify-center transition-colors text-muted-ink"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<h5 className="text-[10px] font-bold uppercase tracking-[0.3em] text-muted-ink whitespace-nowrap">Modèles</h5>
|
||||
<div className="h-px w-full bg-border/40" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ title: 'Veille IA', desc: 'Scrape les flux RSS de 6 sites IA et génère un résumé hebdomadaire.', icon: <Globe size={18} /> },
|
||||
{ title: 'Veille Tech', desc: 'Crée un résumé quotidien des news Hacker News et Product Hunt.', icon: <Zap size={18} /> },
|
||||
{ title: 'Veille Dev', desc: 'Surveille les repos GitHub pour détecter les nouvelles releases.', icon: <Layers size={18} /> },
|
||||
].map((model, i) => (
|
||||
<div key={i} className="bg-white/40 dark:bg-white/5 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-white dark:hover:bg-white/10 hover:border-ink/20 transition-all">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-50 dark:bg-white/10 flex items-center justify-center text-muted-ink group-hover:bg-ink group-hover:text-paper mb-4 transition-all">
|
||||
{model.icon}
|
||||
</div>
|
||||
<h4 className="text-[13px] font-bold text-ink mb-2">{model.title}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-4">{model.desc}</p>
|
||||
<button className="text-[11px] font-bold uppercase tracking-widest text-ink hover:opacity-60 transition-opacity flex items-center gap-2">
|
||||
<Plus size={14} /> Installer
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<header className="px-12 py-10 border-b border-border bg-white dark:bg-paper backdrop-blur-md sticky top-0 z-30">
|
||||
<div className="flex items-center justify-between max-w-5xl mx-auto">
|
||||
<button
|
||||
onClick={() => setSelectedAgentId(null)}
|
||||
className="flex items-center gap-3 text-xs font-bold uppercase tracking-widest text-muted-ink hover:text-ink transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Retour
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="px-5 py-2 text-xs font-bold uppercase tracking-widest border border-border rounded-xl hover:bg-slate-50 dark:hover:bg-white/5 transition-all">
|
||||
Logs
|
||||
</button>
|
||||
<button className="px-6 py-2 bg-ink text-paper text-xs font-bold uppercase tracking-widest rounded-xl hover:opacity-90 transition-all shadow-lg shadow-ink/10">
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 px-12 py-16 max-w-5xl mx-auto w-full space-y-24">
|
||||
<section className="space-y-12">
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete">Sélectionnez le type d'agent</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-4xl mx-auto">
|
||||
{[
|
||||
{ id: 'Surveillant', icon: <Eye size={18} />, label: 'Surveillant', desc: 'Surveille un carnet et analyse les notes' },
|
||||
{ id: 'Personnalisé', icon: <Layers size={18} />, label: 'Personnalisé', desc: 'Agent libre avec votre propre prompt' },
|
||||
{ id: 'Slides', icon: <Layers size={18} />, label: 'Slides', desc: 'Crée une présentation PowerPoint à partir de notes' },
|
||||
{ id: 'Diagramme', icon: <Zap size={18} />, label: 'Diagramme', desc: 'Crée un diagramme Excalidraw à partir de notes' },
|
||||
].map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setAgentType(type.id as any)}
|
||||
className={`p-6 rounded-2xl border-2 transition-all flex flex-col items-center gap-3 text-center group relative
|
||||
${agentType === type.id ? 'border-blueprint bg-white shadow-xl shadow-blueprint/10' : 'border-border bg-white/50 hover:bg-white'}`}
|
||||
>
|
||||
<div className={`p-3 rounded-xl transition-all ${agentType === type.id ? 'bg-blueprint text-white' : 'bg-slate-50 text-concrete group-hover:text-ink'}`}>
|
||||
{type.icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[13px] font-bold text-ink">{type.label}</p>
|
||||
<p className="text-[10px] text-muted-ink leading-tight">{type.desc}</p>
|
||||
</div>
|
||||
<div className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all
|
||||
${agentType === type.id ? 'border-blueprint' : 'border-border opacity-20'}`}>
|
||||
{agentType === type.id && <div className="w-2 h-2 bg-blueprint rounded-full" />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
|
||||
CONFIGURATION <Info size={12} className="opacity-40" />
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-6 py-2 border-2 border-rose-100 bg-rose-50 rounded-xl text-rose-500 text-[11px] font-bold uppercase tracking-widest hover:bg-rose-100 transition-colors">
|
||||
<Trash2 size={14} /> Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-white/5 border border-border/60 rounded-[32px] p-12 space-y-12 shadow-sm">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">DESCRIPTION (OPTIONEL)</label>
|
||||
<Info size={12} className="text-concrete/40" />
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-black/20 border border-border/40 rounded-2xl p-6 text-sm outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all font-light leading-relaxed resize-none text-ink"
|
||||
placeholder="Décrivez brièvement le rôle de cet agent..."
|
||||
defaultValue="Lit une note et génère un diagramme visuel dans le Lab Excalidraw."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">CARNET À SURVEILLER</label>
|
||||
<Info size={12} className="text-concrete/40" />
|
||||
</div>
|
||||
<HierarchicalCarnetSelector
|
||||
carnets={carnets}
|
||||
selectedId={selectedCarnetForAgent}
|
||||
onSelect={setSelectedCarnetForAgent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">NOTES À ANALYSER</label>
|
||||
<Info size={12} className="text-concrete/40" />
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-black/20 border border-border/40 rounded-2xl overflow-hidden divide-y divide-border/20">
|
||||
{[
|
||||
'Résumé du conteneur LXC devSandbox',
|
||||
'Connexion SSH sans mot de passe à devSandbox',
|
||||
'Gateway token (blank to generate)',
|
||||
'Procédure d\'accès à openclaw',
|
||||
'Derniers commits du repo Momento'
|
||||
].map((note, i) => (
|
||||
<label key={i} className="flex items-center gap-4 px-6 py-4 cursor-pointer hover:bg-white/50 transition-colors group">
|
||||
<div className={`w-5 h-5 rounded border transition-all flex items-center justify-center
|
||||
${i === 0 ? 'bg-blueprint border-blueprint text-white' : 'bg-white border-border group-hover:border-blueprint/40'}`}>
|
||||
{i === 0 && <Check size={12} />}
|
||||
</div>
|
||||
<input type="checkbox" className="hidden" defaultChecked={i === 0} />
|
||||
<span className={`text-[13px] transition-colors ${i === 0 ? 'font-medium text-ink' : 'text-muted-ink'}`}>{note}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-concrete/60 italic font-medium">{1} note(s) sélectionnée(s)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">TYPE DE DIAGRAMME</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
|
||||
{[
|
||||
'Auto (détection métier)', 'Flowchart (processus)',
|
||||
'Mindmap (idées)', 'Organigramme (équipes)',
|
||||
'Timeline / roadmap', 'Process map (opérations)',
|
||||
'Architecture cloud (zones/RG)'
|
||||
].map((type, i) => (
|
||||
<button key={i} className={`px-6 py-4 rounded-xl border text-[13px] text-left transition-all
|
||||
${i === 0 ? 'border-ink bg-slate-50 font-bold text-ink ring-2 ring-ink/5' : 'border-border text-concrete hover:border-concrete/40 hover:bg-slate-50/50'}`}>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">STYLE DU DIAGRAMME EXCALIDRAW</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{[
|
||||
'Coloré (Excalidraw)', 'Sketch+ (Excalidraw accentué)', 'Austère (sobre)'
|
||||
].map((style, i) => (
|
||||
<button key={i} className={`px-6 py-4 rounded-xl border text-[13px] transition-all
|
||||
${i === 1 ? 'border-ink bg-white font-bold text-ink ring-2 ring-ink/5 shadow-lg' : 'border-border text-concrete hover:bg-slate-50'}`}>
|
||||
{style}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,445 @@
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Zap,
|
||||
Search,
|
||||
ArrowRight,
|
||||
History,
|
||||
Plus,
|
||||
Wind,
|
||||
PlusCircle,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Maximize2
|
||||
} from 'lucide-react';
|
||||
import { WaveCanvas } from './WaveCanvas';
|
||||
import { BrainstormSession, BrainstormIdea, Note } from '../../types';
|
||||
import { generateBrainstormWave, generateExpansion, getEmbedding, cosineSimilarity } from '../../services/geminiService';
|
||||
|
||||
interface BrainstormViewProps {
|
||||
notes: Note[];
|
||||
onConvertNote: (idea: BrainstormIdea) => void;
|
||||
}
|
||||
|
||||
export const BrainstormView: React.FC<BrainstormViewProps> = ({ notes, onConvertNote }) => {
|
||||
const [seedInput, setSeedInput] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessions, setSessions] = useState<BrainstormSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [ideas, setIdeas] = useState<BrainstormIdea[]>([]);
|
||||
const [selectedIdeaId, setSelectedIdeaId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/brainstorm/sessions')
|
||||
.then(res => res.json())
|
||||
.then(data => setSessions(data))
|
||||
.catch(err => console.error("Failed to load sessions", err));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSessionId) {
|
||||
fetch(`/api/brainstorm/${activeSessionId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.ideas) {
|
||||
setIdeas(prev => {
|
||||
const filtered = prev.filter(i => i.sessionId !== activeSessionId);
|
||||
return [...filtered, ...data.ideas];
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Failed to load ideas", err));
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
const activeSession = useMemo(() =>
|
||||
sessions.find(s => s.id === activeSessionId),
|
||||
[activeSessionId, sessions]);
|
||||
|
||||
const activeIdeas = useMemo(() =>
|
||||
ideas.filter(i => i.sessionId === activeSessionId),
|
||||
[activeSessionId, ideas]);
|
||||
|
||||
const selectedIdea = useMemo(() =>
|
||||
ideas.find(i => i.id === selectedIdeaId),
|
||||
[selectedIdeaId, ideas]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRemoteStart = (e: any) => {
|
||||
if (e.detail?.seed) {
|
||||
handleStartBrainstorm(e.detail.seed, e.detail.sourceNoteId);
|
||||
}
|
||||
};
|
||||
window.addEventListener('start-brainstorm', handleRemoteStart);
|
||||
return () => window.removeEventListener('start-brainstorm', handleRemoteStart);
|
||||
}, [notes]);
|
||||
|
||||
const handleStartBrainstorm = async (seed: string, sourceNoteId?: string) => {
|
||||
if (!seed.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1. Create session on backend
|
||||
const sessionRes = await fetch('/api/brainstorm/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
seedIdea: seed,
|
||||
sourceNoteId
|
||||
})
|
||||
});
|
||||
const session = await sessionRes.json();
|
||||
if (!sessionRes.ok) throw new Error(session.error || "Failed to create session");
|
||||
|
||||
setSessions(prev => [session, ...prev]);
|
||||
setActiveSessionId(session.id);
|
||||
setSeedInput('');
|
||||
|
||||
// 2. Generate waves in frontend concurrently
|
||||
const contextSummaries = notes.slice(0, 5).map(n => n.title).join(', ');
|
||||
|
||||
const wavePromises = [1, 2, 3].map(async (num) => {
|
||||
try {
|
||||
const generated = await generateBrainstormWave(seed, num, contextSummaries);
|
||||
return generated.map(g => ({
|
||||
...g,
|
||||
waveNumber: num
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(`Wave ${num} failed`, e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const wavesResults = await Promise.all(wavePromises);
|
||||
const allNewIdeas = wavesResults.flat();
|
||||
|
||||
if (allNewIdeas.length === 0) {
|
||||
throw new Error("No ideas were generated. Gemini might be shy today.");
|
||||
}
|
||||
|
||||
// 3. Save ideas to backend
|
||||
const ideasRes = await fetch(`/api/brainstorm/${session.id}/ideas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ideas: allNewIdeas })
|
||||
});
|
||||
const savedIdeas = await ideasRes.json();
|
||||
setIdeas(prev => [...prev, ...savedIdeas]);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Brainstorm failed:", err);
|
||||
setError(err.message || "An unexpected error occurred while brainstorming.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateIdea = async (ideaId: string, updates: Partial<BrainstormIdea>) => {
|
||||
try {
|
||||
const res = await fetch(`/api/brainstorm/ideas/${ideaId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
const updated = await res.json();
|
||||
setIdeas(prev => prev.map(i => i.id === ideaId ? updated : i));
|
||||
} catch (err) {
|
||||
console.error("Update failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeepenIdea = async (idea: BrainstormIdea) => {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const generated = await generateExpansion(idea.title, idea.description);
|
||||
const newIdeasData = generated.map(g => ({
|
||||
...g,
|
||||
waveNumber: Math.min(idea.waveNumber + 1, 3),
|
||||
parentIdeaId: idea.id
|
||||
}));
|
||||
|
||||
const res = await fetch(`/api/brainstorm/${idea.sessionId}/ideas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ideas: newIdeasData })
|
||||
});
|
||||
const savedIdeas = await res.json();
|
||||
setIdeas(prev => [...prev, ...savedIdeas]);
|
||||
} catch (err) {
|
||||
console.error("Deepen failed", err);
|
||||
setError("Failed to expand this idea.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissIdea = (ideaId: string) => {
|
||||
updateIdea(ideaId, { status: 'dismissed' });
|
||||
setSelectedIdeaId(null);
|
||||
};
|
||||
|
||||
const handleConvertToNote = (idea: BrainstormIdea) => {
|
||||
updateIdea(idea.id, { status: 'converted' });
|
||||
onConvertNote(idea);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#F8F7F2] dark:bg-[#0A0A0A] overflow-hidden">
|
||||
{/* Header / Start area */}
|
||||
<div className="p-12 border-b border-border/20 backdrop-blur-md bg-white/20 dark:bg-dark-paper/20 z-10 relative overflow-hidden">
|
||||
{/* Architectural Grid Background */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05]"
|
||||
style={{ backgroundImage: 'linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
||||
|
||||
<div className="max-w-4xl mx-auto relative">
|
||||
<div className="flex items-center gap-5 mb-8">
|
||||
<motion.div
|
||||
animate={{ rotate: isGenerating ? 360 : 0 }}
|
||||
transition={{ repeat: isGenerating ? Infinity : 0, duration: 20, ease: "linear" }}
|
||||
className="w-14 h-14 rounded-2xl bg-ochre shadow-[0_0_20px_rgba(212,163,115,0.2)] flex items-center justify-center text-paper"
|
||||
>
|
||||
<Wind size={28} />
|
||||
</motion.div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-serif font-medium text-ink dark:text-dark-ink tracking-tight">Waves of Thought</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="w-8 h-px bg-ochre/40" />
|
||||
<p className="text-[10px] text-concrete tracking-[0.3em] uppercase font-bold">Unfold dimensions of potentiality</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-ochre/20 to-blueprint/20 rounded-[28px] blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-700" />
|
||||
<input
|
||||
type="text"
|
||||
value={seedInput}
|
||||
onChange={(e) => setSeedInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStartBrainstorm(seedInput)}
|
||||
placeholder="Enter a concept to unfold..."
|
||||
className={`w-full relative bg-white dark:bg-[#1A1A1A] border-2 rounded-2xl px-8 py-7 pr-20 outline-none transition-all text-2xl font-serif italic text-ink dark:text-dark-ink shadow-sm group-hover:shadow-md
|
||||
${error ? 'border-rose-400 focus:ring-rose-100 shadow-rose-100' : 'border-border/40 focus:border-ochre/40 focus:ring-4 focus:ring-ochre/5'}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleStartBrainstorm(seedInput)}
|
||||
disabled={isGenerating || !seedInput.trim()}
|
||||
className="absolute right-4 top-4 bottom-4 px-6 bg-ink dark:bg-ochre text-paper rounded-xl disabled:opacity-50 transition-all hover:scale-[1.02] active:scale-[0.98] flex items-center justify-center gap-2 min-w-[70px] shadow-lg"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="w-6 h-6 border-3 border-paper/30 border-t-paper rounded-full animate-spin" />
|
||||
) : (
|
||||
<Plus size={24} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mt-6 p-5 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 rounded-2xl flex items-start gap-4 text-rose-600 dark:text-rose-400 text-sm overflow-hidden shadow-sm"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full bg-rose-100 dark:bg-rose-500/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<div className="w-2 h-2 rounded-full bg-rose-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold uppercase tracking-wider text-[10px] mb-1">Obstruction detected</p>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{isGenerating && !error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-6 flex items-center gap-4 text-ochre/80 italic font-serif"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{[0.2, 0.4, 0.6].map((d, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, delay: d }}
|
||||
className="w-1.5 h-1.5 rounded-full bg-ochre"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-base tracking-tight">Gemini is harvesting seeds of thought from the digital ether...</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{/* Main Canvas Area */}
|
||||
<div className="flex-1 relative bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#ffffff10_1px,transparent_1px)] [background-size:20px_20px]">
|
||||
{activeSession ? (
|
||||
<WaveCanvas
|
||||
session={activeSession}
|
||||
ideas={activeIdeas}
|
||||
onNodeSelect={setSelectedIdeaId}
|
||||
onPositionUpdate={(id, pos) => updateIdea(id, { position: pos })}
|
||||
selectedNodeId={selectedIdeaId}
|
||||
relatedNotes={notes}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20 flex-col gap-6">
|
||||
<Wind size={120} strokeWidth={1} className="text-concrete animate-pulse" />
|
||||
<p className="text-xl font-serif italic text-concrete">The canvas is waiting for your spark...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating UI overlays */}
|
||||
<AnimatePresence>
|
||||
{activeSession && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute bottom-6 left-6 flex gap-2"
|
||||
>
|
||||
<div className="px-4 py-2 bg-paper/80 dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-400 shadow-[0_0_8px_rgba(251,146,60,0.6)]" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 1</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400 shadow-[0_0_8px_rgba(96,165,250,0.6)]" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 2</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-violet-400 shadow-[0_0_8px_rgba(167,139,250,0.6)]" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 3</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar Detail Panel */}
|
||||
<AnimatePresence>
|
||||
{selectedIdea && (
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
className="w-[400px] border-l border-border bg-paper dark:bg-dark-paper flex flex-col z-20 shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
|
||||
>
|
||||
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border
|
||||
${selectedIdea.waveNumber === 1 ? 'border-orange-200 bg-orange-50 text-orange-600' :
|
||||
selectedIdea.waveNumber === 2 ? 'border-blue-200 bg-blue-50 text-blue-600' :
|
||||
'border-violet-200 bg-violet-50 text-violet-600'}`}>
|
||||
Vague {selectedIdea.waveNumber}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIdea.status === 'converted' && (
|
||||
<span className="text-[10px] font-bold text-emerald-500 uppercase tracking-widest bg-emerald-500/10 px-2 py-1 rounded-full">Note Created</span>
|
||||
)}
|
||||
<button onClick={() => setSelectedIdeaId(null)} className="p-2 hover:bg-ink/5 rounded-full transition-colors">
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-serif font-medium text-ink dark:text-dark-ink mb-2">{selectedIdea.title}</h2>
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap size={14} className="text-ochre" />
|
||||
<span className="text-xs font-bold text-concrete">Novelty: {selectedIdea.noveltyScore}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-ink/80 dark:text-dark-ink/80 leading-relaxed font-light mb-10 text-lg">
|
||||
{selectedIdea.description}
|
||||
</p>
|
||||
|
||||
<div className="p-6 bg-slate-50 dark:bg-white/5 rounded-2xl border border-border/40 mb-10">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-widest text-concrete mb-3">Origin connection</h4>
|
||||
<p className="text-sm italic text-muted-ink leading-relaxed">
|
||||
"{selectedIdea.connectionToSeed}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedIdea.relatedNoteIds && selectedIdea.relatedNoteIds.length > 0 && (
|
||||
<div className="space-y-4 mb-10">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-widest text-concrete px-1">Semantic Context</h4>
|
||||
{selectedIdea.relatedNoteIds.map(noteId => {
|
||||
const note = notes.find(n => n.id === noteId);
|
||||
return note ? (
|
||||
<div key={noteId} className="p-4 rounded-xl border border-border bg-white dark:bg-white/5 hover:border-ink/20 transition-all cursor-pointer group">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-ink dark:text-dark-ink truncate">{note.title}</h5>
|
||||
<ArrowRight size={14} className="text-concrete group-hover:text-ink transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => handleDeepenIdea(selectedIdea)}
|
||||
disabled={isGenerating}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-ochre/40 hover:bg-ochre/5 transition-all group disabled:opacity-50"
|
||||
>
|
||||
<Wind size={24} className="text-concrete group-hover:text-ochre mb-2" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink">Deepen</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleConvertToNote(selectedIdea)}
|
||||
disabled={selectedIdea.status === 'converted'}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-blueprint/40 hover:bg-blueprint/5 transition-all group disabled:opacity-50"
|
||||
>
|
||||
<FileText size={24} className="text-concrete group-hover:text-blueprint mb-2" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink">Extract</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleDismissIdea(selectedIdea.id)}
|
||||
className="w-full py-4 text-[10px] font-bold uppercase tracking-[0.2em] text-concrete hover:text-rose-500 hover:bg-rose-500/5 rounded-xl transition-all border border-transparent hover:border-rose-500/10"
|
||||
>
|
||||
Not pertinent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* History Rail */}
|
||||
<div className="w-16 border-l border-border flex flex-col items-center py-6 gap-6 bg-paper dark:bg-dark-paper z-10">
|
||||
<History size={18} className="text-concrete" />
|
||||
<div className="w-px flex-1 bg-border/40" />
|
||||
<div className="flex flex-col gap-3 overflow-y-auto px-2 custom-scrollbar">
|
||||
{sessions.map(session => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => setActiveSessionId(session.id)}
|
||||
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0
|
||||
${activeSessionId === session.id ? 'bg-ink text-paper scale-110 shadow-lg' : 'bg-paper dark:bg-white/10 text-concrete hover:bg-black/5 hover:text-ink'}`}
|
||||
title={session.seedIdea}
|
||||
>
|
||||
{session.seedIdea.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-px h-12 bg-border/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { BrainstormSession, BrainstormIdea, Note } from '../../types';
|
||||
|
||||
interface WaveCanvasProps {
|
||||
session: BrainstormSession;
|
||||
ideas: BrainstormIdea[];
|
||||
onNodeSelect: (id: string) => void;
|
||||
onPositionUpdate: (id: string, pos: { x: number; y: number }) => void;
|
||||
selectedNodeId: string | null;
|
||||
relatedNotes: Note[];
|
||||
}
|
||||
|
||||
export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
session,
|
||||
ideas,
|
||||
onNodeSelect,
|
||||
onPositionUpdate,
|
||||
selectedNodeId,
|
||||
relatedNotes
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current) return;
|
||||
|
||||
const width = containerRef.current.clientWidth;
|
||||
const height = containerRef.current.clientHeight;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const g = svg.append("g");
|
||||
|
||||
// Zoom behavior
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 5])
|
||||
.on("zoom", (event) => {
|
||||
g.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
// Initial transform to center
|
||||
svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8));
|
||||
|
||||
// Data structures for d3
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
type: 'root' | 'idea' | 'note';
|
||||
wave?: number;
|
||||
title: string;
|
||||
color: string;
|
||||
radius: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
||||
source: string | D3Node;
|
||||
target: string | D3Node;
|
||||
type: 'wave' | 'context' | 'parent';
|
||||
}
|
||||
|
||||
const nodes: D3Node[] = [];
|
||||
const links: D3Link[] = [];
|
||||
|
||||
// Root node
|
||||
const rootNode: D3Node = {
|
||||
id: 'root',
|
||||
type: 'root',
|
||||
title: session.seedIdea,
|
||||
color: '#141414',
|
||||
radius: 40,
|
||||
fx: 0,
|
||||
fy: 0
|
||||
};
|
||||
nodes.push(rootNode);
|
||||
|
||||
// Idea nodes
|
||||
const colors = {
|
||||
1: '#fb923c', // orange
|
||||
2: '#60a5fa', // blue
|
||||
3: '#a78bfa' // violet
|
||||
};
|
||||
|
||||
ideas.forEach(idea => {
|
||||
nodes.push({
|
||||
id: idea.id,
|
||||
type: 'idea',
|
||||
wave: idea.waveNumber,
|
||||
title: idea.title,
|
||||
color: colors[idea.waveNumber as 1|2|3] || '#94a3b8',
|
||||
radius: 28,
|
||||
status: idea.status,
|
||||
x: idea.position?.x,
|
||||
y: idea.position?.y
|
||||
});
|
||||
|
||||
if (idea.parentIdeaId) {
|
||||
links.push({
|
||||
source: idea.parentIdeaId,
|
||||
target: idea.id,
|
||||
type: 'parent'
|
||||
});
|
||||
} else {
|
||||
links.push({
|
||||
source: 'root',
|
||||
target: idea.id,
|
||||
type: 'wave'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Radial layout forces
|
||||
const simulation = d3.forceSimulation<D3Node>(nodes)
|
||||
.force("link", d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(d => {
|
||||
if (d.type === 'wave') {
|
||||
const targetNode = nodes.find(n => n.id === (typeof d.target === 'string' ? d.target : (d.target as any).id));
|
||||
return (targetNode?.wave || 1) * 200;
|
||||
}
|
||||
if (d.type === 'parent') return 180;
|
||||
return 100;
|
||||
}))
|
||||
.force("charge", d3.forceManyBody().strength(-800))
|
||||
.force("radial", d3.forceRadial<D3Node>(d => {
|
||||
if (d.type === 'root') return 0;
|
||||
if (d.id.includes('-')) return (d.wave || 1) * 200 + 100; // Deepened ideas push out
|
||||
return (d.wave || 1) * 200;
|
||||
}, 0, 0).strength(0.8))
|
||||
.force("collision", d3.forceCollide<D3Node>().radius(d => d.radius + 30));
|
||||
|
||||
// Drawing rings
|
||||
const ringRadii = [200, 400, 600];
|
||||
g.selectAll(".ring")
|
||||
.data(ringRadii)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("class", "ring")
|
||||
.attr("r", d => d)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#e2e8f0")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-dasharray", "4,4")
|
||||
.style("opacity", 0.5);
|
||||
|
||||
// Links
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("stroke", d => d.type === 'wave' ? "#cbd5e1" : d.type === 'parent' ? "#fde047" : "#94a3b8")
|
||||
.attr("stroke-width", d => d.type === 'wave' ? 1.5 : 2)
|
||||
.attr("stroke-dasharray", d => d.type === 'parent' ? "none" : "4,4");
|
||||
|
||||
// Nodes
|
||||
const node = g.append("g")
|
||||
.selectAll(".node")
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "node")
|
||||
.style("opacity", d => d.status === 'dismissed' ? 0.4 : 1)
|
||||
.on("click", (event, d) => {
|
||||
if (d.type === 'idea') onNodeSelect(d.id);
|
||||
})
|
||||
.call(d3.drag<SVGGElement, D3Node>()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended) as any);
|
||||
|
||||
node.append("circle")
|
||||
.attr("r", d => d.radius)
|
||||
.attr("fill", d => d.status === 'converted' ? '#ecfdf5' : (d.type === 'root' ? '#141414' : '#fff'))
|
||||
.attr("stroke", d => d.status === 'converted' ? '#10b981' : d.color)
|
||||
.attr("stroke-width", d => d.id === selectedNodeId ? 4 : 2)
|
||||
.attr("class", "cursor-pointer transition-all hover:scale-110")
|
||||
.style("filter", d => d.id === selectedNodeId ? `drop-shadow(0 0 12px ${d.color}cc)` : "none");
|
||||
|
||||
// State indicators (converted)
|
||||
node.filter(d => d.status === 'converted')
|
||||
.append("path")
|
||||
.attr("d", d3.symbol().type(d3.symbolCircle).size(150))
|
||||
.attr("fill", "#10b981");
|
||||
|
||||
// Icons/Text in nodes
|
||||
node.append("text")
|
||||
.attr("dy", d => d.type === 'root' ? ".35em" : d.radius + 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", d => d.type === 'root' ? "#fff" : (d.status === 'dismissed' ? "#94a3b8" : "#141414"))
|
||||
.attr("class", d => d.type === 'root' ? "text-[10px] font-bold pointer-events-none tracking-widest" : "text-[11px] font-bold uppercase tracking-tight pointer-events-none")
|
||||
.text(d => d.type === 'root' ? "SEED" : d.title.length > 18 ? d.title.substring(0, 18) + "..." : d.title);
|
||||
|
||||
if (rootNode) {
|
||||
g.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dy", 80)
|
||||
.attr("class", "text-2xl font-serif italic fill-ink dark:fill-dark-ink pointer-events-none shadow-sm")
|
||||
.text(session.seedIdea);
|
||||
}
|
||||
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => (d.source as any).x)
|
||||
.attr("y1", d => (d.source as any).y)
|
||||
.attr("x2", d => (d.target as any).x)
|
||||
.attr("y2", d => (d.target as any).y);
|
||||
|
||||
node
|
||||
.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
function dragstarted(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event: any, d: D3Node) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
if (d.type === 'idea') {
|
||||
onPositionUpdate(d.id, { x: event.x, y: event.y });
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
simulation.stop();
|
||||
};
|
||||
}, [session, ideas, selectedNodeId, onNodeSelect]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full relative cursor-grab active:cursor-grabbing">
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
<div className="absolute top-6 left-6 pointer-events-none">
|
||||
<p className="text-[10px] font-bold tracking-[0.3em] uppercase text-concrete opacity-40">Spatial Exploration Mode</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Check,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
import { Carnet } from '../types';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
interface HierarchicalCarnetSelectorProps {
|
||||
carnets: Carnet[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const HierarchicalCarnetSelector: React.FC<HierarchicalCarnetSelectorProps> = ({
|
||||
carnets,
|
||||
selectedId,
|
||||
onSelect,
|
||||
className = "",
|
||||
placeholder = "Sélectionner un carnet..."
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(['1', '4'])); // Default expand some
|
||||
|
||||
const selectedCarnet = carnets.find(c => c.id === selectedId);
|
||||
|
||||
// Derive the path for display
|
||||
const path = useMemo(() => {
|
||||
if (!selectedCarnet) return [];
|
||||
const trail: Carnet[] = [];
|
||||
let current = selectedCarnet;
|
||||
while (current) {
|
||||
trail.unshift(current);
|
||||
if (!current.parentId) break;
|
||||
const parent = carnets.find(c => c.id === current.parentId);
|
||||
if (!parent) break;
|
||||
current = parent;
|
||||
}
|
||||
return trail;
|
||||
}, [selectedCarnet, carnets]);
|
||||
|
||||
const toggleExpand = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
const newExpanded = new Set(expandedIds);
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id);
|
||||
} else {
|
||||
newExpanded.add(id);
|
||||
}
|
||||
setExpandedIds(newExpanded);
|
||||
};
|
||||
|
||||
const filteredCarnets = useMemo(() => {
|
||||
if (!searchQuery) return carnets;
|
||||
return carnets.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}, [carnets, searchQuery]);
|
||||
|
||||
const renderTree = (parentId?: string, level = 0) => {
|
||||
const children = carnets.filter(c => c.parentId === parentId);
|
||||
if (children.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={level > 0 ? "ml-4 border-l border-border/40 pl-2" : ""}>
|
||||
{children.map(carnet => {
|
||||
const isExpanded = expandedIds.has(carnet.id) || searchQuery.length > 0;
|
||||
const hasChildren = carnets.some(c => c.parentId === carnet.id);
|
||||
const isSelected = selectedId === carnet.id;
|
||||
|
||||
// If searching and this carnet doesn't match AND none of its children match, skip it
|
||||
if (searchQuery && !carnet.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
const hasMatchingChild = (id: string): boolean => {
|
||||
const childrenOfId = carnets.filter(c => c.parentId === id);
|
||||
return childrenOfId.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()) || hasMatchingChild(c.id));
|
||||
};
|
||||
if (!hasMatchingChild(carnet.id)) return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={carnet.id} className="select-none">
|
||||
<div
|
||||
onClick={() => {
|
||||
onSelect(carnet.id);
|
||||
if (!searchQuery) setIsOpen(false);
|
||||
}}
|
||||
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
|
||||
${isSelected ? 'bg-blueprint/10 text-blueprint font-bold' : 'hover:bg-slate-50 dark:hover:bg-white/5 text-ink'}`}
|
||||
>
|
||||
<div className="w-4 flex items-center justify-center">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => toggleExpand(e, carnet.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`p-1 rounded ${isSelected ? 'bg-blueprint/20' : 'bg-slate-100 dark:bg-white/5 group-hover:bg-white/40'}`}>
|
||||
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||
</div>
|
||||
|
||||
<span className="text-[13px] truncate flex-1">{carnet.name}</span>
|
||||
|
||||
{isSelected && <Check size={14} className="opacity-60" />}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{renderTree(carnet.id, level + 1)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full bg-slate-50 dark:bg-black/20 border border-border/80 rounded-xl px-4 py-4 text-sm outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all cursor-pointer text-ink flex items-center gap-3"
|
||||
>
|
||||
<Folder size={16} className="text-blueprint/60 shrink-0" />
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
{path.length > 0 ? (
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
{path.map((item, i) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{i > 0 && <span className="text-concrete/40 text-[10px]">/</span>}
|
||||
<span className={`truncate ${i === path.length - 1 ? 'font-bold' : 'text-concrete'}`}>
|
||||
{item.name}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-concrete italic">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown size={14} className={`transition-transform duration-300 text-concrete shrink-0 ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[60]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
className="absolute z-[70] mt-2 w-full bg-white dark:bg-dark-paper border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col min-w-[280px]"
|
||||
>
|
||||
<div className="p-3 border-b border-border/40 bg-slate-50/50">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Filtrer les carnets..."
|
||||
className="w-full bg-white border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-blueprint transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto custom-scrollbar p-2">
|
||||
{renderTree(undefined)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-border/40 bg-slate-50/30 flex justify-between items-center px-4">
|
||||
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">
|
||||
Structure des carnets
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-[10px] font-bold text-blueprint hover:underline"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
248
architectural-grid11/src/components/InsightsView.tsx
Normal file
248
architectural-grid11/src/components/InsightsView.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Network,
|
||||
Lightbulb,
|
||||
Layers,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
Trophy,
|
||||
Zap,
|
||||
Tag,
|
||||
Link as LinkIcon
|
||||
} from 'lucide-react';
|
||||
import { Note, NoteCluster, BridgeNote, ConnectionSuggestion } from '../types';
|
||||
import { runClustering, detectBridges, calculateCentroid } from '../services/clusteringService';
|
||||
import { nameCluster, suggestBridgeIdeas } from '../services/geminiService';
|
||||
import { NetworkGraph } from './NetworkGraph';
|
||||
|
||||
interface InsightsViewProps {
|
||||
notes: Note[];
|
||||
onUpdateNotes: (updatedNotes: Note[]) => void;
|
||||
onNoteSelect: (noteId: string) => void;
|
||||
}
|
||||
|
||||
export const InsightsView: React.FC<InsightsViewProps> = ({
|
||||
notes,
|
||||
onUpdateNotes,
|
||||
onNoteSelect
|
||||
}) => {
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [clusters, setClusters] = useState<NoteCluster[]>([]);
|
||||
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<ConnectionSuggestion[]>([]);
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null);
|
||||
|
||||
const performAnalysis = async () => {
|
||||
setIsCalculating(true);
|
||||
try {
|
||||
// 1. Run clustering
|
||||
const { clusters: newClusters } = runClustering(notes);
|
||||
|
||||
// 2. Name clusters (first 5 unique notes per cluster)
|
||||
const namedClusters = await Promise.all(newClusters.map(async (c) => {
|
||||
const clusterNoteSummaries = notes
|
||||
.filter(n => c.noteIds.includes(n.id))
|
||||
.slice(0, 5)
|
||||
.map(n => n.title);
|
||||
|
||||
const name = await nameCluster(clusterNoteSummaries);
|
||||
const centroid = calculateCentroid(c.noteIds, notes);
|
||||
|
||||
return { ...c, name, centroid };
|
||||
}));
|
||||
|
||||
// 3. Update notes with cluster IDs
|
||||
const updatedNotes = notes.map(n => {
|
||||
const cluster = namedClusters.find(c => c.noteIds.includes(n.id));
|
||||
return { ...n, clusterId: cluster?.id };
|
||||
});
|
||||
onUpdateNotes(updatedNotes);
|
||||
|
||||
// 4. Detect bridges
|
||||
const bridges = detectBridges(updatedNotes, namedClusters);
|
||||
|
||||
// 5. Build suggestions for isolated cluster pairs
|
||||
// For demo, we'll just pick a few interesting pairs
|
||||
const newSuggestions: ConnectionSuggestion[] = [];
|
||||
if (namedClusters.length >= 2) {
|
||||
// Find clusters with no mutual bridge notes or low connectivity
|
||||
for (let i = 0; i < Math.min(namedClusters.length, 3); i++) {
|
||||
for (let j = i + 1; j < Math.min(namedClusters.length, 3); j++) {
|
||||
const cA = namedClusters[i];
|
||||
const cB = namedClusters[j];
|
||||
|
||||
const cA_notes = updatedNotes.filter(n => cA.noteIds.includes(n.id)).map(n => n.title).join(', ');
|
||||
const cB_notes = updatedNotes.filter(n => cB.noteIds.includes(n.id)).map(n => n.title).join(', ');
|
||||
|
||||
const bridgeIdeas = await suggestBridgeIdeas(cA.name, cB.name, cA_notes, cB_notes);
|
||||
bridgeIdeas.forEach((idea, idx) => {
|
||||
newSuggestions.push({
|
||||
id: `suggestion-${i}-${j}-${idx}`,
|
||||
...idea,
|
||||
clusterAId: cA.id,
|
||||
clusterBId: cB.id
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setClusters(namedClusters);
|
||||
setBridgeNotes(bridges);
|
||||
setSuggestions(newSuggestions);
|
||||
} catch (error) {
|
||||
console.error("Analysis failed:", error);
|
||||
} finally {
|
||||
setIsCalculating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (notes.some(n => n.embedding) && clusters.length === 0) {
|
||||
performAnalysis();
|
||||
}
|
||||
}, [notes]);
|
||||
|
||||
const bridgeList = useMemo(() => {
|
||||
return bridgeNotes.map(b => {
|
||||
const note = notes.find(n => n.id === b.noteId);
|
||||
return { ...b, title: note?.title || 'Unknown Note' };
|
||||
});
|
||||
}, [bridgeNotes, notes]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-paper dark:bg-[#0D0D0D] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-8 border-b border-border/40 flex items-center justify-between backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center text-indigo-500">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Semantic Insights</h1>
|
||||
</div>
|
||||
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Discovering the hidden architecture of your knowledge</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={performAnalysis}
|
||||
disabled={isCalculating}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isCalculating ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
{isCalculating ? 'Mapping...' : 'Re-sync Network'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: Graph View */}
|
||||
<div className="flex-[1.5] p-6 relative">
|
||||
<NetworkGraph
|
||||
notes={notes}
|
||||
clusters={clusters}
|
||||
bridgeNotes={bridgeNotes}
|
||||
onNoteSelect={onNoteSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Insight Dashboard */}
|
||||
<div className="flex-1 border-l border-border/40 flex flex-col h-full bg-paper/50 dark:bg-black/10 backdrop-blur-sm overflow-hidden">
|
||||
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar space-y-12">
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-indigo-500 mb-2">
|
||||
<Layers size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Clusters</span>
|
||||
</div>
|
||||
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{clusters.length}</div>
|
||||
</div>
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-ochre mb-2">
|
||||
<Trophy size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Bridge Notes</span>
|
||||
</div>
|
||||
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bridge Notes Section */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6 px-1">
|
||||
<Zap size={16} className="text-ochre" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Powerful Bridge Notes</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{bridgeList.map(bridge => (
|
||||
<motion.div
|
||||
key={bridge.noteId}
|
||||
whileHover={{ x: 4 }}
|
||||
onClick={() => onNoteSelect(bridge.noteId)}
|
||||
className="p-4 rounded-xl bg-white dark:bg-white/5 border border-border hover:border-ochre/40 transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">{bridge.title}</h4>
|
||||
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
||||
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{bridge.connectedClusterIds.map(cid => {
|
||||
const c = clusters.find(cl => cl.id === cid);
|
||||
return (
|
||||
<div key={cid} className="flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c?.color }} />
|
||||
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{c?.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{bridgeList.length === 0 && !isCalculating && (
|
||||
<div className="text-xs text-concrete italic">No significant bridge notes found yet. Deepen your research to find new connections.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connection Suggestions */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6 px-1">
|
||||
<Lightbulb size={16} className="text-indigo-500" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Missing Links (AI Generated)</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{suggestions.map((s, idx) => (
|
||||
<div key={s.id} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 to-transparent border border-indigo-500/10 hover:border-indigo-500/30 transition-all">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex -space-x-2">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[10px] text-white">A</div>
|
||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
|
||||
</div>
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">Bridging {clusters.find(c => c.id === s.clusterAId)?.name} & {clusters.find(c => c.id === s.clusterBId)?.name}</span>
|
||||
</div>
|
||||
<h4 className="text-base font-serif font-medium text-ink dark:text-dark-ink mb-2">{s.title}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.description}</p>
|
||||
<div className="p-3 bg-white/40 dark:bg-white/5 rounded-xl border border-border/40 text-[10px] italic text-concrete flex gap-2">
|
||||
<Zap size={12} className="shrink-0" />
|
||||
<span>{s.reasoning}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isCalculating && (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
173
architectural-grid11/src/components/NetworkGraph.tsx
Normal file
173
architectural-grid11/src/components/NetworkGraph.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { Note, NoteCluster, BridgeNote } from '../types';
|
||||
|
||||
interface NetworkGraphProps {
|
||||
notes: Note[];
|
||||
clusters: NoteCluster[];
|
||||
bridgeNotes: BridgeNote[];
|
||||
onNoteSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export const NetworkGraph: React.FC<NetworkGraphProps> = ({
|
||||
notes,
|
||||
clusters,
|
||||
bridgeNotes,
|
||||
onNoteSelect
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current) return;
|
||||
|
||||
const width = containerRef.current.clientWidth;
|
||||
const height = containerRef.current.clientHeight;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const g = svg.append("g");
|
||||
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on("zoom", (event) => {
|
||||
g.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
// Filter notes with embeddings and cluster assignments
|
||||
const visibleNotes = notes.filter(n => n.embedding && n.clusterId);
|
||||
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
title: string;
|
||||
clusterId: string;
|
||||
color: string;
|
||||
isBridge: boolean;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
||||
source: string;
|
||||
target: string;
|
||||
strength: number;
|
||||
}
|
||||
|
||||
const bridgeSet = new Set(bridgeNotes.map(b => b.noteId));
|
||||
|
||||
const nodes: D3Node[] = visibleNotes.map(n => {
|
||||
const cluster = clusters.find(c => c.id === n.clusterId);
|
||||
const isBridge = bridgeSet.has(n.id);
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
clusterId: n.clusterId!,
|
||||
color: cluster?.color || '#cbd5e1',
|
||||
isBridge,
|
||||
radius: isBridge ? 12 : 8
|
||||
};
|
||||
});
|
||||
|
||||
const links: D3Link[] = [];
|
||||
// Only connect strong links
|
||||
for (let i = 0; i < visibleNotes.length; i++) {
|
||||
for (let j = i + 1; j < visibleNotes.length; j++) {
|
||||
const ni = visibleNotes[i];
|
||||
const nj = visibleNotes[j];
|
||||
|
||||
if (ni.clusterId === nj.clusterId) {
|
||||
links.push({ source: ni.id, target: nj.id, strength: 0.5 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const simulation = d3.forceSimulation<D3Node>(nodes)
|
||||
.force("link", d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(100))
|
||||
.force("charge", d3.forceManyBody().strength(-200))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collision", d3.forceCollide<D3Node>().radius(d => d.radius + 10));
|
||||
|
||||
// Links
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("stroke", "#e2e8f0")
|
||||
.attr("stroke-opacity", 0.6)
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Nodes
|
||||
const node = g.append("g")
|
||||
.selectAll(".node")
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "node cursor-pointer")
|
||||
.on("click", (event, d) => onNoteSelect(d.id))
|
||||
.call(d3.drag<SVGGElement, D3Node>()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended) as any);
|
||||
|
||||
node.append("circle")
|
||||
.attr("r", d => d.radius)
|
||||
.attr("fill", d => d.color)
|
||||
.attr("stroke", d => d.isBridge ? "#D4AF37" : "#fff")
|
||||
.attr("stroke-width", d => d.isBridge ? 3 : 2)
|
||||
.style("filter", d => d.isBridge ? "drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))" : "none");
|
||||
|
||||
node.append("text")
|
||||
.attr("dy", d => d.radius + 14)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("class", "text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none")
|
||||
.text(d => d.title.length > 20 ? d.title.substring(0, 20) + "..." : d.title);
|
||||
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => (d.source as any).x)
|
||||
.attr("y1", d => (d.source as any).y)
|
||||
.attr("x2", d => (d.target as any).x)
|
||||
.attr("y2", d => (d.target as any).y);
|
||||
|
||||
node
|
||||
.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
function dragstarted(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event: any, d: D3Node) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
|
||||
return () => simulation.stop();
|
||||
}, [notes, clusters, bridgeNotes, onNoteSelect]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
|
||||
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-3 max-w-[300px]">
|
||||
{clusters.map(c => (
|
||||
<div key={c.id} className="flex items-center gap-1.5 px-2 py-1 bg-white/80 dark:bg-white/5 backdrop-blur-sm border border-border rounded-full shadow-sm">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-concrete whitespace-nowrap">{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
489
architectural-grid11/src/components/NotebooksView.tsx
Normal file
489
architectural-grid11/src/components/NotebooksView.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Pin,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
MoreVertical,
|
||||
Sparkles,
|
||||
Tag as TagIcon,
|
||||
X,
|
||||
BookOpen,
|
||||
Edit3,
|
||||
Eye,
|
||||
Trash2,
|
||||
Wind
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Note, Carnet, Tag } from '../types';
|
||||
import { SlashMenu } from './SlashMenu';
|
||||
|
||||
interface NotebooksViewProps {
|
||||
activeNoteId: string | null;
|
||||
activeCarnet: Carnet | undefined;
|
||||
filteredNotes: Note[];
|
||||
activeNote: Note | undefined;
|
||||
setActiveNoteId: (id: string | null) => void;
|
||||
togglePin: (id: string) => void;
|
||||
setShowNewNoteModal: (show: boolean) => void;
|
||||
isAISidebarOpen: boolean;
|
||||
setIsAISidebarOpen: (open: boolean) => void;
|
||||
selectedTagIds: string[];
|
||||
setSelectedTagIds: (ids: string[]) => void;
|
||||
allNotes: Note[];
|
||||
activeCarnetId: string;
|
||||
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
|
||||
onDeleteNote: (id: string) => void;
|
||||
onBrainstormNote: (note: Note) => void;
|
||||
}
|
||||
|
||||
export const NotebooksView: React.FC<NotebooksViewProps> = ({
|
||||
activeNoteId,
|
||||
activeCarnet,
|
||||
filteredNotes,
|
||||
activeNote,
|
||||
setActiveNoteId,
|
||||
togglePin,
|
||||
setShowNewNoteModal,
|
||||
isAISidebarOpen,
|
||||
setIsAISidebarOpen,
|
||||
selectedTagIds,
|
||||
setSelectedTagIds,
|
||||
allNotes,
|
||||
activeCarnetId,
|
||||
setShowNewCarnetModal,
|
||||
onDeleteNote,
|
||||
onBrainstormNote
|
||||
}) => {
|
||||
const [isTagsExpanded, setIsTagsExpanded] = React.useState(false);
|
||||
const [tagSearchQuery, setTagSearchQuery] = React.useState('');
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [slashMenu, setSlashMenu] = React.useState<{ isOpen: boolean; top: number; left: number } | null>(null);
|
||||
|
||||
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === '/') {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
setSlashMenu({
|
||||
isOpen: true,
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const insertCommand = (type: string) => {
|
||||
console.log(`Command selected: ${type}`);
|
||||
setSlashMenu(null);
|
||||
};
|
||||
|
||||
const availableTags = React.useMemo(() => {
|
||||
const carnetNotes = allNotes.filter(n => n.carnetId === activeCarnetId);
|
||||
const tagsMap = new Map<string, Tag>();
|
||||
carnetNotes.forEach(note => {
|
||||
note.tags?.forEach(tag => {
|
||||
tagsMap.set(tag.id, tag);
|
||||
});
|
||||
});
|
||||
return Array.from(tagsMap.values()).sort((a, b) => {
|
||||
// AI tags first, then alphabetical
|
||||
if (a.type === 'ai' && b.type !== 'ai') return -1;
|
||||
if (a.type !== 'ai' && b.type === 'ai') return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}, [allNotes, activeCarnetId]);
|
||||
|
||||
const visibleTags = React.useMemo(() => {
|
||||
let filtered = availableTags;
|
||||
if (tagSearchQuery) {
|
||||
filtered = availableTags.filter(t =>
|
||||
t.label.toLowerCase().includes(tagSearchQuery.toLowerCase())
|
||||
);
|
||||
} else if (!isTagsExpanded) {
|
||||
filtered = availableTags.slice(0, 10);
|
||||
// Ensure selected tags are always visible even if not in the first 10
|
||||
selectedTagIds.forEach(id => {
|
||||
if (!filtered.find(t => t.id === id)) {
|
||||
const tag = availableTags.find(t => t.id === id);
|
||||
if (tag) filtered.push(tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, [availableTags, isTagsExpanded, tagSearchQuery, selectedTagIds]);
|
||||
|
||||
const toggleTag = (tagId: string) => {
|
||||
if (selectedTagIds.includes(tagId)) {
|
||||
setSelectedTagIds(selectedTagIds.filter(id => id !== tagId));
|
||||
} else {
|
||||
setSelectedTagIds([...selectedTagIds, tagId]);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeNoteId) {
|
||||
return (
|
||||
<div 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
|
||||
onClick={() => setShowNewCarnetModal(true, activeCarnetId)}
|
||||
className="flex items-center gap-2 text-[13px] text-concrete font-medium hover:text-ink transition-all"
|
||||
>
|
||||
<BookOpen size={16} />
|
||||
<span>New Sub-Carnet</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>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-[0.2em] text-concrete">
|
||||
<TagIcon size={12} />
|
||||
<span>Filter by Tags</span>
|
||||
{selectedTagIds.length > 0 && (
|
||||
<span className="bg-blueprint/10 text-blueprint px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
|
||||
{selectedTagIds.length} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{availableTags.length > 10 && (
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
className="bg-transparent border-b border-border/40 text-[10px] outline-none focus:border-blueprint/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-concrete/40"
|
||||
onChange={(e) => setTagSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center min-h-[32px]">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visibleTags.map(tag => {
|
||||
const isActive = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<motion.button
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all border flex items-center gap-2
|
||||
${isActive
|
||||
? 'bg-ink text-paper border-ink shadow-lg shadow-ink/10'
|
||||
: 'bg-white/40 border-border text-concrete hover:border-concrete/40 hover:bg-white/60'}`}
|
||||
>
|
||||
{tag.type === 'ai' && (
|
||||
<Sparkles
|
||||
size={10}
|
||||
className={isActive ? 'text-blueprint' : 'text-blueprint/60'}
|
||||
/>
|
||||
)}
|
||||
{tag.label}
|
||||
{isActive && <X size={10} />}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{availableTags.length > 10 && !tagSearchQuery && (
|
||||
<button
|
||||
onClick={() => setIsTagsExpanded(!isTagsExpanded)}
|
||||
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-concrete/60 hover:text-ink transition-colors border border-dashed border-border rounded-full"
|
||||
>
|
||||
{isTagsExpanded ? 'Show less' : `+ ${availableTags.length - 10} more`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedTagIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedTagIds([])}
|
||||
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-rust hover:underline ml-auto"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</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 relative"
|
||||
onClick={() => setActiveNoteId(note.id)}
|
||||
>
|
||||
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
|
||||
<span className="flex items-center gap-3">
|
||||
{note.isPinned && <Pin size={18} className="text-amber-500 fill-amber-500" />}
|
||||
{note.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onBrainstormNote(note);
|
||||
}}
|
||||
className="p-2 rounded-full opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-ochre/10 text-ochre transition-all"
|
||||
title="Brainstorm this concept"
|
||||
>
|
||||
<Wind size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePin(note.id);
|
||||
}}
|
||||
className={`p-2 rounded-full transition-all ${note.isPinned ? 'text-amber-600 bg-amber-50' : 'opacity-0 group-hover:opacity-60 hover:bg-slate-100 text-ink'}`}
|
||||
>
|
||||
<Pin size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteNote(note.id);
|
||||
}}
|
||||
className="p-2 rounded-full opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-rose-50 text-rose-500 transition-all"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</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 dark:bg-white/5 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">
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{note.tags?.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border flex items-center gap-1.5
|
||||
${tag.type === 'ai'
|
||||
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
||||
: 'bg-concrete/5 border-border text-concrete'}`}
|
||||
>
|
||||
{tag.type === 'ai' && <Sparkles size={8} />}
|
||||
{tag.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex overflow-hidden transition-all duration-500">
|
||||
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-paper">
|
||||
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 dark:bg-paper/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={() => onBrainstormNote(activeNote!)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-ochre/30 text-ochre hover:bg-ochre/5 transition-all"
|
||||
>
|
||||
<Wind size={16} />
|
||||
<span className="text-xs font-bold uppercase tracking-widest">Brainstorm</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg border transition-all duration-300
|
||||
${isEditing ? 'bg-blueprint text-white border-blueprint shadow-lg shadow-blueprint/20' : 'border-border text-ink hover:bg-slate-50'}`}
|
||||
>
|
||||
{isEditing ? <Eye size={16} /> : <Edit3 size={16} />}
|
||||
<span className="text-xs font-bold uppercase tracking-widest">{isEditing ? 'Visualiser' : 'Modifier'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => togglePin(activeNoteId!)}
|
||||
className={`p-2 rounded-full transition-all ${activeNote?.isPinned ? 'text-amber-600 bg-amber-50 dark:bg-ochre/10' : 'text-muted-ink hover:text-ink'}`}
|
||||
title={activeNote?.isPinned ? "Unpin note" : "Pin note"}
|
||||
>
|
||||
<Pin size={18} className={activeNote?.isPinned ? 'fill-amber-600' : ''} />
|
||||
</button>
|
||||
<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 dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
<span className="text-xs font-medium">AI Assistant</span>
|
||||
</button>
|
||||
<button className="p-2 text-muted-ink hover:text-red-500 transition-colors">
|
||||
<Trash2 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 relative">
|
||||
<AnimatePresence>
|
||||
{slashMenu?.isOpen && (
|
||||
<SlashMenu
|
||||
position={{ top: slashMenu.top, left: slashMenu.left }}
|
||||
onSelect={(type) => insertCommand(type)}
|
||||
onClose={() => setSlashMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
|
||||
<span className="text-blueprint">{activeCarnet?.name}</span>
|
||||
<ChevronRight size={10} className="text-concrete" />
|
||||
<span className="text-concrete">{activeNote?.date}</span>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={activeNote?.title}
|
||||
className="w-full text-5xl md:text-6xl font-serif font-bold text-ink leading-tight bg-transparent border-none outline-none focus:ring-0 placeholder:text-concrete/20"
|
||||
placeholder="Titre de la note..."
|
||||
/>
|
||||
) : (
|
||||
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
|
||||
{activeNote?.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{activeNote?.tags?.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border flex items-center gap-2
|
||||
${tag.type === 'ai'
|
||||
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
||||
: 'bg-paper border-border text-concrete'}`}
|
||||
>
|
||||
{tag.type === 'ai' && <Sparkles size={12} />}
|
||||
{tag.label}
|
||||
{tag.type === 'ai' && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blueprint animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-white/5 rounded-xl overflow-hidden shadow-2xl relative group/img">
|
||||
<img
|
||||
src={activeNote?.imageUrl}
|
||||
alt={activeNote?.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover/img:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-ink/20 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto w-full space-y-8 pb-40">
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
defaultValue={activeNote?.content}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
className="w-full min-h-[500px] text-lg leading-relaxed text-ink/90 font-serif bg-transparent border-none outline-none focus:ring-0 resize-none placeholder:text-concrete/20"
|
||||
placeholder="Commencez à écrire... Tapez '/' pour les commandes."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<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" />
|
||||
<div className="space-y-6">
|
||||
{activeNote?.content.split('\n').map((line, i) => (
|
||||
<p key={i} className="text-lg leading-relaxed text-ink/80 font-light text-justify selection:bg-blueprint/20">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
{activeNote?.id.startsWith('n-') && (
|
||||
<p className="text-lg leading-relaxed text-ink/80 font-light text-justify border-l-2 border-blueprint/20 pl-6 italic">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
architectural-grid11/src/components/SettingsView.tsx
Normal file
66
architectural-grid11/src/components/SettingsView.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { SettingsTab } from '../types';
|
||||
import { SettingsHeader } from './settings/SettingsHeader';
|
||||
import { GeneralTab } from './settings/GeneralTab';
|
||||
import { AITab } from './settings/AITab';
|
||||
import { AppearanceTab } from './settings/AppearanceTab';
|
||||
|
||||
interface SettingsViewProps {
|
||||
activeSettingsTab: SettingsTab;
|
||||
setActiveSettingsTab: (tab: SettingsTab) => void;
|
||||
}
|
||||
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
activeSettingsTab,
|
||||
setActiveSettingsTab
|
||||
}) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-paper dark:bg-dark-paper overflow-y-auto custom-scrollbar relative font-sans">
|
||||
<div className="absolute inset-0 opacity-[0.04] pointer-events-none grainy-bg mix-blend-multiply dark:mix-blend-overlay" />
|
||||
|
||||
<div className="relative z-10 flex flex-col min-h-full">
|
||||
<SettingsHeader
|
||||
activeTab={activeSettingsTab}
|
||||
setActiveTab={setActiveSettingsTab}
|
||||
/>
|
||||
|
||||
<div className="flex-1 px-12 pb-24 h-full">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeSettingsTab === 'general' && (
|
||||
<GeneralTab key="general" />
|
||||
)}
|
||||
|
||||
{activeSettingsTab === 'ai' && (
|
||||
<AITab key="ai" />
|
||||
)}
|
||||
|
||||
{activeSettingsTab === 'appearance' && (
|
||||
<AppearanceTab key="appearance" />
|
||||
)}
|
||||
|
||||
{['profile', 'data', 'mcp', 'about'].includes(activeSettingsTab) && (
|
||||
<motion.div
|
||||
key="placeholder"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="h-[50vh] flex flex-col items-center justify-center border border-dashed border-border rounded-[32px] space-y-6 bg-white/20 dark:bg-white/5"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-3xl border border-dashed border-concrete/20 flex items-center justify-center text-concrete/40 bg-paper/50">
|
||||
<span className="text-2xl font-serif italic text-concrete">?</span>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-ink font-bold text-sm tracking-tight">Section en développement</p>
|
||||
<p className="text-concrete italic text-[11px] font-light">Le module {activeSettingsTab} sera disponible prochainement.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
569
architectural-grid11/src/components/Sidebar.tsx
Normal file
569
architectural-grid11/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Archive,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Microscope,
|
||||
Activity,
|
||||
Pin,
|
||||
Moon,
|
||||
Sun,
|
||||
Bell,
|
||||
Lock,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Users,
|
||||
Clock,
|
||||
GripVertical,
|
||||
Wind,
|
||||
Network
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { NavigationView, Carnet, Note } from '../types';
|
||||
|
||||
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 dark:bg-white/10 text-ink font-medium' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 truncate">
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${isActive ? 'bg-ink' : 'bg-transparent border border-muted-ink/30'}`} />
|
||||
<span className="truncate">{note.title}</span>
|
||||
</div>
|
||||
{note.isPinned && <Pin size={10} className="text-amber-500 shrink-0" />}
|
||||
</motion.button>
|
||||
);
|
||||
|
||||
interface SidebarItemProps {
|
||||
carnet: Carnet;
|
||||
isActive: boolean;
|
||||
notes: Note[];
|
||||
activeNoteId: string | null;
|
||||
onCarnetClick: () => void;
|
||||
onNoteClick: (noteId: string) => void;
|
||||
onAddSubCarnet: () => void;
|
||||
onRename: () => void;
|
||||
onDelete: () => void;
|
||||
children?: React.ReactNode;
|
||||
level: number;
|
||||
isExpanded: boolean;
|
||||
toggleExpand: () => void;
|
||||
onMove?: (draggedId: string, targetId?: string) => void;
|
||||
}
|
||||
|
||||
const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
carnet,
|
||||
isActive,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onCarnetClick,
|
||||
onNoteClick,
|
||||
onAddSubCarnet,
|
||||
onRename,
|
||||
onDelete,
|
||||
children,
|
||||
level,
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
onMove
|
||||
}) => {
|
||||
const hasChildren = React.Children.count(children) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div
|
||||
className="flex items-center group relative h-10"
|
||||
style={{ paddingLeft: `${level * 12}px` }}
|
||||
>
|
||||
{/* Subtle Drag Handle */}
|
||||
<div className="absolute left-[-2px] opacity-0 group-hover:opacity-40 cursor-grab active:cursor-grabbing text-concrete transition-opacity z-10">
|
||||
<GripVertical size={10} />
|
||||
</div>
|
||||
|
||||
{/* Hierarchy Guide Line */}
|
||||
{level > 0 && (
|
||||
<div className="absolute left-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
|
||||
)}
|
||||
{level > 0 && (
|
||||
<div className="absolute left-[8px] top-1/2 w-[8px] h-px bg-border/40" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand();
|
||||
}}
|
||||
className="p-1 hover:bg-ink/5 dark:hover:bg-white/5 rounded-md transition-colors text-muted-ink"
|
||||
>
|
||||
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
||||
<ChevronRight size={14} />
|
||||
</motion.div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6" /> // Spacer for alignment
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
whileHover={{ x: 2 }}
|
||||
className={`flex-1 flex items-center gap-2 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative
|
||||
${isActive ? 'bg-white shadow-sm border border-border/40 dark:bg-white/10' : 'hover:bg-white/40 dark:hover:bg-white/5'}`}
|
||||
onClick={onCarnetClick}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.currentTarget.classList.add('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
const draggedId = e.dataTransfer.getData('carnetId');
|
||||
console.log('Dropped carnet:', draggedId, 'on target:', carnet.id);
|
||||
if (draggedId && draggedId !== carnet.id) {
|
||||
onMove?.(draggedId, carnet.id);
|
||||
}
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
console.log('Starting drag for carnet:', carnet.id);
|
||||
e.dataTransfer.setData('carnetId', carnet.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
const ghost = e.currentTarget.cloneNode(true) as HTMLElement;
|
||||
ghost.style.position = 'absolute';
|
||||
ghost.style.top = '-1000px';
|
||||
ghost.style.opacity = '0.5';
|
||||
document.body.appendChild(ghost);
|
||||
e.dataTransfer.setDragImage(ghost, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(ghost), 0);
|
||||
}}
|
||||
>
|
||||
{/* active indicator dot */}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-indicator"
|
||||
className="absolute -left-1 w-1 h-4 bg-blueprint rounded-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border transition-all
|
||||
${isActive ? 'bg-blueprint text-white border-blueprint' : 'bg-paper dark:bg-white/10 text-concrete border-border dark:border-white/10'}`}>
|
||||
{carnet.initial}
|
||||
</div>
|
||||
<div className="flex-1 text-left flex items-center gap-2 min-w-0">
|
||||
<span className={`text-[12px] font-medium transition-colors truncate ${isActive ? 'text-ink' : 'text-muted-ink group-hover:text-ink'}`}>
|
||||
{carnet.name}
|
||||
</span>
|
||||
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddSubCarnet();
|
||||
}}
|
||||
className="p-1 hover:bg-ink/10 dark:hover:bg-white/10 rounded-md transition-all text-concrete hover:text-ink"
|
||||
title="Add sub-carnet"
|
||||
>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRename();
|
||||
}}
|
||||
className="p-1 hover:bg-ink/10 dark:hover:bg-white/10 rounded-md transition-all text-concrete hover:text-ink"
|
||||
title="Rename"
|
||||
>
|
||||
<Edit3 size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-all text-concrete hover:text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
|
||||
{notes.length > 0 && (
|
||||
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover:text-concrete transition-colors">
|
||||
{notes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{(isExpanded || (isActive && !hasChildren)) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative" style={{ marginLeft: `${(level + 1) * 12 + 10}px` }}>
|
||||
{/* Vertical line for nested content */}
|
||||
<div className="absolute left-[-6px] top-0 bottom-4 w-px bg-border/30" />
|
||||
|
||||
<div className="space-y-1 py-1">
|
||||
{children}
|
||||
{isActive && !hasChildren && notes.map(note => (
|
||||
<NoteLink
|
||||
key={note.id}
|
||||
note={note}
|
||||
isActive={activeNoteId === note.id}
|
||||
onClick={() => onNoteClick(note.id)}
|
||||
/>
|
||||
))}
|
||||
{isActive && !hasChildren && notes.length === 0 && (
|
||||
<p className="pl-8 py-2 text-[10px] italic text-concrete/40 font-light">
|
||||
No notes found
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
activeView: NavigationView;
|
||||
isDarkMode: boolean;
|
||||
setIsDarkMode: (val: boolean) => void;
|
||||
setActiveView: (view: NavigationView) => void;
|
||||
carnets: Carnet[];
|
||||
notes: Note[];
|
||||
activeCarnetId: string;
|
||||
activeNoteId: string | null;
|
||||
setActiveCarnetId: (id: string) => void;
|
||||
setActiveNoteId: (id: string | null) => void;
|
||||
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
|
||||
onDeleteCarnet: (id: string) => void;
|
||||
onMoveCarnet: (draggedId: string, targetId?: string) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
activeView,
|
||||
isDarkMode,
|
||||
setIsDarkMode,
|
||||
setActiveView,
|
||||
carnets,
|
||||
notes,
|
||||
activeCarnetId,
|
||||
activeNoteId,
|
||||
setActiveCarnetId,
|
||||
setActiveNoteId,
|
||||
setShowNewCarnetModal,
|
||||
onDeleteCarnet,
|
||||
onMoveCarnet
|
||||
}) => {
|
||||
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set(['4'])); // Default expand Research
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
const newSet = new Set(expandedIds);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
setExpandedIds(newSet);
|
||||
};
|
||||
|
||||
const renderCarnetTree = (parentId: string | undefined = undefined, level: number = 0) => {
|
||||
return carnets
|
||||
.filter(c => c.parentId === parentId && !c.isDeleted)
|
||||
.map(carnet => (
|
||||
<SidebarItem
|
||||
key={carnet.id}
|
||||
carnet={carnet}
|
||||
isActive={activeCarnetId === carnet.id}
|
||||
notes={notes.filter(n => n.carnetId === carnet.id && !n.isDeleted)}
|
||||
activeNoteId={activeNoteId}
|
||||
level={level}
|
||||
isExpanded={expandedIds.has(carnet.id)}
|
||||
toggleExpand={() => toggleExpand(carnet.id)}
|
||||
onAddSubCarnet={() => {
|
||||
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
|
||||
setShowNewCarnetModal(true, carnet.id);
|
||||
}}
|
||||
onRename={() => {
|
||||
setShowNewCarnetModal(true, undefined, true, carnet.id);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDeleteCarnet(carnet.id);
|
||||
}}
|
||||
onCarnetClick={() => {
|
||||
setActiveCarnetId(carnet.id);
|
||||
setActiveNoteId(null);
|
||||
// Auto expand when clicking
|
||||
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
|
||||
}}
|
||||
onNoteClick={(id) => {
|
||||
setActiveCarnetId(carnet.id);
|
||||
setActiveNoteId(id);
|
||||
}}
|
||||
onMove={onMoveCarnet}
|
||||
>
|
||||
{renderCarnetTree(carnet.id, level + 1)}
|
||||
</SidebarItem>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-80 bg-white/30 dark:bg-[#151515] backdrop-blur-md border-r border-border p-6 flex flex-col z-20 shrink-0 transition-colors duration-500">
|
||||
<div className="mb-10 flex items-center justify-between">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-200 dark:bg-white/10 border border-border flex items-center justify-center text-ink font-serif text-lg shadow-sm">
|
||||
A
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
className="p-2 text-muted-ink hover:text-ink transition-all bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10"
|
||||
>
|
||||
{isDarkMode ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
|
||||
<button className="p-2 text-muted-ink hover:text-ink transition-all relative group bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10">
|
||||
<Bell size={14} />
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-rose-500 text-white text-[9px] font-bold flex items-center justify-center rounded-full border border-white shadow-sm">
|
||||
3
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex bg-white/50 dark:bg-white/10 p-1 rounded-full border border-border dark:border-white/10 transition-all">
|
||||
<button
|
||||
onClick={() => setActiveView('notebooks')}
|
||||
className={`p-1.5 rounded-full transition-all ${activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
|
||||
title="Carnets"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('reminders')}
|
||||
className={`p-1.5 rounded-full transition-all ${activeView === 'reminders' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
|
||||
title="Rappels"
|
||||
>
|
||||
<Clock size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('agents')}
|
||||
className={`p-1.5 rounded-full transition-all ${activeView === 'agents' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
|
||||
title="Agents"
|
||||
>
|
||||
<Bot size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-8 -mx-2 px-2 py-4 custom-scrollbar">
|
||||
{activeView === 'notebooks' ? (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-1 rounded-lg transition-colors group/header"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.currentTarget.classList.add('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
const draggedId = e.dataTransfer.getData('carnetId');
|
||||
console.log('Dropped carnet on root:', draggedId);
|
||||
if (draggedId) {
|
||||
onMoveCarnet(draggedId, undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">
|
||||
Architecture Grid
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewCarnetModal(true)}
|
||||
className="p-1 hover:bg-paper dark:hover:bg-white/5 rounded-md text-concrete hover:text-ink transition-colors"
|
||||
title="New Carnet"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-0.5">
|
||||
{renderCarnetTree()}
|
||||
</nav>
|
||||
</div>
|
||||
) : activeView === 'shared' ? (
|
||||
<div className="space-y-6">
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4">
|
||||
Partagé avec moi
|
||||
</p>
|
||||
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
|
||||
<Users size={24} className="mx-auto text-concrete/40 mb-3" />
|
||||
<p className="text-[11px] text-concrete italic">Aucun document partagé pour le moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : activeView === 'reminders' ? (
|
||||
<div className="space-y-6">
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4">
|
||||
Rappels programmés
|
||||
</p>
|
||||
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
|
||||
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
|
||||
<p className="text-[11px] text-concrete italic">Aucun rappel actif.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : activeView === 'agents' ? (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-muted-ink tracking-widest uppercase mb-4 px-4">
|
||||
Intelligence OS
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ id: 'a1', name: 'Mes Agents', icon: <Bot size={16} /> },
|
||||
{ id: 'a2', name: 'Le Lab AI', icon: <Microscope size={16} /> },
|
||||
{ id: 'a3', name: 'Activités', icon: <Activity size={16} /> },
|
||||
].map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
|
||||
${item.id === 'a1' ? 'active-nav-item' : 'text-muted-ink hover:bg-white/40 dark:hover:bg-white/5 hover:text-ink'}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center border transition-colors
|
||||
${item.id === 'a1' ? 'bg-ink text-paper border-ink' : 'bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-ink/20'}`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-[13px] font-medium">{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-1">
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4 mb-2">
|
||||
Capabilities
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveView('brainstorm')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-ochre hover:bg-ochre/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-ochre/20">
|
||||
<Wind size={16} />
|
||||
</div>
|
||||
<span className="flex-1 text-left">Brainstorm Wave</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('insights')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-indigo-500 hover:bg-indigo-500/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-indigo-500/20">
|
||||
<Network size={16} />
|
||||
</div>
|
||||
<span className="flex-1 text-left">Semantic Network</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('temporal')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-rose-500 hover:bg-rose-500/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-rose-500/20">
|
||||
<Clock size={16} />
|
||||
</div>
|
||||
<span className="flex-1 text-left">Temporal Forecast</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border/40 mt-auto pb-4">
|
||||
<div className="px-2 space-y-0.5">
|
||||
<button
|
||||
onClick={() => setActiveView('shared')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'shared' ? 'bg-blueprint/5 text-blueprint' : 'text-muted-ink hover:text-ink hover:bg-black/5'}`}
|
||||
>
|
||||
<Users size={14} className={activeView === 'shared' ? 'text-blueprint' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<span className="flex-1 text-left">Partagé</span>
|
||||
</button>
|
||||
|
||||
<button className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-black/5 transition-all font-medium group rounded-xl">
|
||||
<Archive size={14} className="text-muted-ink group-hover:text-ink" />
|
||||
<span className="flex-1 text-left">Archives</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('trash')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'trash' ? 'bg-rose-50 text-rose-500' : 'text-muted-ink hover:text-rose-500 hover:bg-rose-50/50'}`}
|
||||
>
|
||||
<Trash2 size={14} className={activeView === 'trash' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
|
||||
<span className="flex-1 text-left">Corbeille</span>
|
||||
{notes.some(n => n.isDeleted) && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-rose-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="my-4 pt-4 border-t border-border/20">
|
||||
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase px-3 mb-2 opacity-60">Intelligence</p>
|
||||
<button
|
||||
onClick={() => setActiveView('brainstorm')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'brainstorm' ? 'bg-ochre/10 text-ochre' : 'text-muted-ink hover:text-ochre hover:bg-ochre/5'}`}
|
||||
>
|
||||
<Wind size={14} className={activeView === 'brainstorm' ? 'text-ochre' : 'text-muted-ink group-hover:text-ochre'} />
|
||||
<span className="flex-1 text-left">Brainstorm Wave</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('insights')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'insights' ? 'bg-indigo-500/10 text-indigo-500' : 'text-muted-ink hover:text-indigo-500 hover:bg-indigo-500/5'}`}
|
||||
>
|
||||
<Network size={14} className={activeView === 'insights' ? 'text-indigo-500' : 'text-muted-ink group-hover:text-indigo-500'} />
|
||||
<span className="flex-1 text-left">Semantic Network</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('temporal')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'temporal' ? 'bg-rose-500/10 text-rose-500' : 'text-muted-ink hover:text-rose-500 hover:bg-rose-500/5'}`}
|
||||
>
|
||||
<Clock size={14} className={activeView === 'temporal' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
|
||||
<span className="flex-1 text-left">Temporal Forecast</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="my-2 h-px bg-border/20 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('settings')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'settings' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-black/5'}`}
|
||||
>
|
||||
<Settings size={14} className={activeView === 'settings' ? 'text-paper' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<span className="flex-1 text-left">Paramètres</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
65
architectural-grid11/src/components/SlashMenu.tsx
Normal file
65
architectural-grid11/src/components/SlashMenu.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
List,
|
||||
Quote,
|
||||
Code,
|
||||
Image as ImageIcon,
|
||||
Type,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
interface SlashMenuProps {
|
||||
position: { top: number; left: number };
|
||||
onSelect: (type: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SlashMenu: React.FC<SlashMenuProps> = ({ position, onSelect, onClose }) => {
|
||||
const commands = [
|
||||
{ id: 'h1', label: 'Titre Principal', icon: <Heading1 size={14} />, desc: 'Grand titre de section' },
|
||||
{ id: 'h2', label: 'Sous-titre', icon: <Heading2 size={14} />, desc: 'Titre de niveau 2' },
|
||||
{ id: 'bullet', label: 'Liste à puces', icon: <List size={14} />, desc: 'Liste simple' },
|
||||
{ id: 'quote', label: 'Citation', icon: <Quote size={14} />, desc: 'Bloc de texte mis en avant' },
|
||||
{ id: 'code', label: 'Bloc de Code', icon: <Code size={14} />, desc: 'Code ou texte technique' },
|
||||
{ id: 'image', label: 'Image', icon: <ImageIcon size={14} />, desc: 'Insérer un visuel' },
|
||||
{ id: 'ai-summary', label: 'Résumé IA', icon: <Sparkles size={14} />, desc: 'Générer un résumé court', special: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[60]" onClick={onClose} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
className="fixed z-[70] w-64 bg-white dark:bg-[#1A1A1A] border border-border shadow-2xl rounded-xl overflow-hidden py-2"
|
||||
style={{ top: position.top, left: position.left }}
|
||||
>
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-concrete uppercase tracking-widest border-b border-border/40 mb-1">
|
||||
Commandes rapides
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto custom-scrollbar">
|
||||
{commands.map((cmd) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
onClick={() => onSelect(cmd.id)}
|
||||
className="w-full flex items-start gap-3 px-3 py-2 hover:bg-paper dark:hover:bg-white/5 transition-colors group text-left"
|
||||
>
|
||||
<div className={`p-2 rounded-lg border border-border transition-colors group-hover:border-ink/20
|
||||
${cmd.special ? 'bg-blueprint/10 text-blueprint border-blueprint/20' : 'bg-white/50 dark:bg-white/5 text-ink'}`}>
|
||||
{cmd.icon}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-bold text-ink">{cmd.label}</p>
|
||||
<p className="text-[10px] text-muted-ink leading-tight">{cmd.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
169
architectural-grid11/src/components/TemporalView.tsx
Normal file
169
architectural-grid11/src/components/TemporalView.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Clock,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
Zap,
|
||||
History,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
PieChart
|
||||
} from 'lucide-react';
|
||||
import { Note, NoteAccessLog, NotePrediction } from '../types';
|
||||
import { predictNextAccess, detectAccessCycle } from '../services/temporalService';
|
||||
|
||||
interface TemporalViewProps {
|
||||
notes: Note[];
|
||||
accessLogs: NoteAccessLog[];
|
||||
onNoteSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export const TemporalView: React.FC<TemporalViewProps> = ({ notes, accessLogs, onNoteSelect }) => {
|
||||
const predictions = useMemo(() => {
|
||||
return notes
|
||||
.map(note => {
|
||||
const noteLogs = accessLogs.filter(l => l.noteId === note.id);
|
||||
const prediction = predictNextAccess(note, noteLogs);
|
||||
return { note, prediction };
|
||||
})
|
||||
.filter(p => p.prediction !== null) as { note: Note; prediction: NotePrediction }[];
|
||||
}, [notes, accessLogs]);
|
||||
|
||||
const cyclicalNotes = useMemo(() => {
|
||||
return notes
|
||||
.map(note => {
|
||||
const noteLogs = accessLogs.filter(l => l.noteId === note.id);
|
||||
const cycle = detectAccessCycle(noteLogs);
|
||||
return { note, cycle };
|
||||
})
|
||||
.filter(p => p.cycle !== null) as { note: Note; cycle: number }[];
|
||||
}, [notes, accessLogs]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-paper dark:bg-[#0A0A0A] overflow-hidden">
|
||||
<div className="p-8 border-b border-border/40 backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-rose-500/10 flex items-center justify-center text-rose-500">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Temporal Forecast</h1>
|
||||
</div>
|
||||
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Predicting the recurrence of insight</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
|
||||
<div className="max-w-5xl mx-auto space-y-12">
|
||||
|
||||
{/* Daily Briefing / Predictions */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Sparkles size={16} className="text-rose-400" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Intelligence Briefing</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{predictions.map(({ note, prediction }) => (
|
||||
<motion.div
|
||||
key={note.id}
|
||||
whileHover={{ y: -4 }}
|
||||
onClick={() => onNoteSelect(note.id)}
|
||||
className="p-6 rounded-2xl bg-white dark:bg-white/5 border border-border/60 hover:border-rose-400/40 transition-all cursor-pointer shadow-sm relative overflow-hidden group"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-3">
|
||||
<TrendingUp size={14} className="text-rose-400 opacity-40 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="px-2 py-1 bg-rose-500/10 text-rose-500 text-[10px] font-bold rounded-full uppercase tracking-widest">
|
||||
Coming Up
|
||||
</span>
|
||||
<span className="text-[10px] text-concrete font-medium">
|
||||
{new Date(prediction.predictedRelevanceDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="text-lg font-serif font-medium text-ink dark:text-dark-ink mb-2 group-hover:text-rose-500 transition-colors uppercase tracking-tight">{note.title}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-6 italic">
|
||||
"{prediction.reason}"
|
||||
</p>
|
||||
|
||||
<div className="pt-4 border-t border-border/40 flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-concrete uppercase tracking-widest">Confidence {Math.round(prediction.confidence * 100)}%</span>
|
||||
<ArrowRight size={14} className="text-concrete group-hover:text-ink" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{predictions.length === 0 && (
|
||||
<div className="col-span-full p-12 text-center bg-paper dark:bg-white/5 border-2 border-dashed border-border rounded-3xl">
|
||||
<Calendar size={40} className="mx-auto text-concrete/40 mb-4" />
|
||||
<h5 className="text-ink dark:text-dark-ink font-medium mb-1">No upcoming predictions</h5>
|
||||
<p className="text-xs text-concrete">The system needs more usage data to find cyclical patterns in your research.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cyclical Patterns */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<History size={16} className="text-indigo-400" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Detected Cycles</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{cyclicalNotes.map(({ note, cycle }) => (
|
||||
<div key={note.id} className="flex items-center gap-4 p-4 rounded-xl bg-slate-50 dark:bg-white/5 border border-border/40">
|
||||
<div className="w-12 h-12 rounded-xl bg-white dark:bg-black/20 border border-border flex items-center justify-center flex-col shadow-sm">
|
||||
<span className="text-xs font-bold text-ink dark:text-dark-ink">{Math.round(cycle)}</span>
|
||||
<span className="text-[8px] font-bold text-concrete uppercase">days</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="text-sm font-medium text-ink dark:text-dark-ink">{note.title}</h5>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-concrete">Recurring theme in your architectural process</span>
|
||||
<div className="h-1 w-24 bg-border/40 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-rose-400" style={{ width: '65%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => onNoteSelect(note.id)} className="p-2 hover:bg-black/5 rounded-full">
|
||||
<ArrowRight size={16} className="text-concrete" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Productivity Stats */}
|
||||
<section className="bg-ink dark:bg-white/5 rounded-3xl p-8 text-paper relative overflow-hidden">
|
||||
<div className="relative z-10 grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h6 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-60 mb-2">Memory Strength</h6>
|
||||
<div className="text-4xl font-serif">84%</div>
|
||||
<p className="text-[10px] opacity-60 mt-2">Active connections in your semantic network</p>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-60 mb-2">Peak Cycle</h6>
|
||||
<div className="text-4xl font-serif">28 Days</div>
|
||||
<p className="text-[10px] opacity-60 mt-2">The rhythm of your creative output</p>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-2 h-2 rounded-full bg-rose-400"></div>
|
||||
<span className="text-xs font-medium">4 Notes resurfacing this week</span>
|
||||
</div>
|
||||
<button className="w-full py-2 bg-paper text-ink rounded-xl text-[10px] font-bold uppercase tracking-widest hover:scale-105 transition-transform">
|
||||
Build Morning Briefing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-rose-500/10 blur-[80px] rounded-full -mr-20 -mt-20"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
218
architectural-grid11/src/components/TrashView.tsx
Normal file
218
architectural-grid11/src/components/TrashView.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
X,
|
||||
FileText,
|
||||
Folder,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Note, Carnet } from '../types';
|
||||
|
||||
interface TrashViewProps {
|
||||
deletedNotes: Note[];
|
||||
deletedCarnets: Carnet[];
|
||||
onRestoreNote: (id: string) => void;
|
||||
onRestoreCarnet: (id: string) => void;
|
||||
onPermanentDeleteNote: (id: string) => void;
|
||||
onPermanentDeleteCarnet: (id: string) => void;
|
||||
onEmptyTrash: () => void;
|
||||
}
|
||||
|
||||
export const TrashView: React.FC<TrashViewProps> = ({
|
||||
deletedNotes,
|
||||
deletedCarnets,
|
||||
onRestoreNote,
|
||||
onRestoreCarnet,
|
||||
onPermanentDeleteNote,
|
||||
onPermanentDeleteCarnet,
|
||||
onEmptyTrash
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [filterType, setFilterType] = React.useState<'all' | 'notes' | 'carnets'>('all');
|
||||
|
||||
const getDaysRemaining = (dateString?: string) => {
|
||||
if (!dateString) return 30;
|
||||
const deletedDate = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = now.getTime() - deletedDate.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
return Math.max(0, 30 - diffDays);
|
||||
};
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
const items = [
|
||||
...deletedNotes.map(n => ({ ...n, itemType: 'note' as const })),
|
||||
...deletedCarnets.map(c => ({ ...c, itemType: 'carnet' as const }))
|
||||
];
|
||||
|
||||
return items
|
||||
.filter(item => {
|
||||
const matchesSearch = ('title' in item ? item.title : item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesType = filterType === 'all' || (filterType === 'notes' && item.itemType === 'note') || (filterType === 'carnets' && item.itemType === 'carnet');
|
||||
return matchesSearch && matchesType;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = a.deletedAt ? new Date(a.deletedAt).getTime() : 0;
|
||||
const dateB = b.deletedAt ? new Date(b.deletedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
}, [deletedNotes, deletedCarnets, searchQuery, filterType]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-dark-paper">
|
||||
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-[#F9F8F6]/80 backdrop-blur-md z-30 border-b border-border/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-4xl font-serif font-medium text-ink flex items-center gap-4">
|
||||
Corbeille <Trash2 size={28} className="text-rose-400 opacity-40" />
|
||||
</h1>
|
||||
<p className="text-[10px] text-concrete font-bold uppercase tracking-[0.3em] opacity-60">
|
||||
Auto-suppression après 30 jours
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{filteredItems.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Vider la corbeille ? Cette action est irréversible.')) {
|
||||
onEmptyTrash();
|
||||
}
|
||||
}}
|
||||
className="px-6 py-3 bg-paper border border-border text-rose-500 rounded-2xl text-[10px] font-bold uppercase tracking-widest hover:bg-rose-50 hover:border-rose-100 transition-all shadow-sm"
|
||||
>
|
||||
Vider tout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="group relative flex-1 max-w-xl">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-concrete group-focus-within:text-ink transition-colors" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-white dark:bg-white/5 border border-border/40 rounded-2xl pl-12 pr-6 py-4 text-sm outline-none focus:ring-4 ring-ink/5 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-white dark:bg-white/5 p-1 rounded-2xl border border-border shadow-sm">
|
||||
{(['all', 'notes', 'carnets'] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setFilterType(type)}
|
||||
className={`px-6 py-3 rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all
|
||||
${filterType === type ? 'bg-ink text-paper shadow-lg' : 'text-concrete hover:text-ink'}`}
|
||||
>
|
||||
{type === 'all' ? 'Tous' : type === 'notes' ? 'Notes' : 'Carnets'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-12 py-12 overflow-y-auto custom-scrollbar">
|
||||
{filteredItems.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredItems.map((item) => {
|
||||
const daysLeft = getDaysRemaining(item.deletedAt);
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-white dark:bg-white/5 border border-border/60 rounded-[32px] p-8 group hover:shadow-2xl hover:border-blueprint/20 transition-all relative overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Countdown Progress Bar */}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-slate-100 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(daysLeft / 30) * 100}%` }}
|
||||
className={`h-full ${daysLeft < 5 ? 'bg-rose-500' : 'bg-blueprint'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-3 rounded-2xl ${item.itemType === 'note' ? 'bg-blueprint/10 text-blueprint' : 'bg-concrete/10 text-concrete'}`}>
|
||||
{item.itemType === 'note' ? <FileText size={20} /> : <Folder size={20} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => item.itemType === 'note' ? onRestoreNote(item.id) : onRestoreCarnet(item.id)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-50 text-emerald-600 rounded-xl text-[10px] font-bold uppercase tracking-widest hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
<RotateCcw size={12} /> Restaurer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => item.itemType === 'note' ? onPermanentDeleteNote(item.id) : onPermanentDeleteCarnet(item.id)}
|
||||
className="p-2 hover:bg-rose-50 text-rose-500 rounded-xl transition-colors"
|
||||
title="Supprimer définitivement"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-8 flex-1">
|
||||
<h3 className="text-base font-serif font-medium text-ink leading-tight">
|
||||
{'title' in item ? item.title : item.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded border ${daysLeft < 5 ? 'border-rose-200 text-rose-500 bg-rose-50' : 'border-blueprint/20 text-blueprint bg-blueprint/5'}`}>
|
||||
{daysLeft} JOURS RESTANTS
|
||||
</div>
|
||||
<span className="text-[10px] text-concrete font-medium uppercase tracking-tight flex items-center gap-1">
|
||||
<Clock size={10} /> {('deletedAt' in item && item.deletedAt) ? new Date(item.deletedAt).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.itemType === 'note' && 'content' in item ? (
|
||||
<div className="text-[12px] text-concrete line-clamp-3 leading-relaxed opacity-60 font-light border-t border-border/40 pt-4">
|
||||
{item.content.replace(/[#*`]/g, '')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-border/40 pt-4">
|
||||
<div className="text-[9px] font-bold text-concrete/40 uppercase tracking-widest">
|
||||
Contenu du dossier préservé
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center space-y-6 opacity-40">
|
||||
<div className="p-8 rounded-full bg-slate-100 border-2 border-dashed border-border flex items-center justify-center">
|
||||
<Trash2 size={64} className="text-concrete" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-serif text-ink italic">Corbeille vide</h2>
|
||||
<p className="text-sm text-concrete max-w-xs">
|
||||
Les éléments que vous supprimez apparaîtront ici. Ils seront conservés pendant 30 jours avant suppression définitive.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="px-12 py-6 bg-white/50 border-t border-border flex items-center gap-4">
|
||||
<AlertCircle size={14} className="text-concrete" />
|
||||
<p className="text-[10px] text-concrete font-medium uppercase tracking-widest">
|
||||
Conseil : La restauration d'un carnet restaurera également toutes les notes à l'intérieur.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
152
architectural-grid11/src/components/settings/AITab.tsx
Normal file
152
architectural-grid11/src/components/settings/AITab.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { Sparkles, Edit3, MessageCircle, Languages, Tag, History, FlaskConical } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const AISettingCard = ({ icon, title, description, defaultChecked = false }: any) => (
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-6 flex items-center justify-between group hover:shadow-xl hover:shadow-blueprint/5 transition-all duration-300">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-blueprint/10 rounded-2xl text-blueprint group-hover:bg-blueprint group-hover:text-white group-hover:scale-110 transition-all duration-300 border border-blueprint/20">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-ink">{title}</h4>
|
||||
<p className="text-[10px] text-muted-ink leading-relaxed pr-4 line-clamp-2">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked={defaultChecked} />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-blueprint"></div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AITab: React.FC = () => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-16 pb-20"
|
||||
>
|
||||
<div className="space-y-10">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-muted-ink opacity-60">Configurez vos fonctionnalités IA et préférences</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-sm font-bold text-ink border-b border-border/40 pb-4">Fonctionnalités IA</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<AISettingCard
|
||||
icon={<Edit3 size={18} />}
|
||||
title="Suggestions de titre"
|
||||
description="Suggérer des titres pour les notes sans titre après 50+ mots"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<Sparkles size={18} />}
|
||||
title="IA Note"
|
||||
description="Active le bouton de chat IA et les outils d'amélioration du texte"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<MessageCircle size={18} />}
|
||||
title="💡 J'ai remarqué quelque chose..."
|
||||
description="Aperçu quotidien de vos notes"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<Languages size={18} />}
|
||||
title="Détection de langue"
|
||||
description="Détecte automatiquement la langue de vos notes"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<Tag size={18} />}
|
||||
title="Suggestion des labels"
|
||||
description="Suggère et applique des étiquettes automatiquement à vos notes"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<History size={18} />}
|
||||
title="Historique des notes"
|
||||
description="Active les snapshots de versions et la restauration depuis History"
|
||||
defaultChecked
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6">
|
||||
{/* Fréquence */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
|
||||
<div className="space-y-1.5 text-left text-blueprint">
|
||||
<h4 className="text-sm font-bold">Fréquence</h4>
|
||||
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">Fréquence d'analyse des connexions</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="freq" className="sr-only peer" defaultChecked />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">Quotidienne</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="freq" className="sr-only peer" />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">Hebdomadaire</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode d'historique */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
|
||||
<div className="space-y-1.5 text-left text-blueprint">
|
||||
<h4 className="text-sm font-bold">Mode d'historique</h4>
|
||||
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">Gestion des snapshots</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="hist" className="sr-only peer" defaultChecked />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-bold text-ink">Manuel (bouton commit)</p>
|
||||
<p className="text-[10px] text-muted-ink">Créer des snapshots manuellement</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="hist" className="sr-only peer" />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-bold text-ink">Automatique (intelligent)</p>
|
||||
<p className="text-[10px] text-muted-ink">Snapshots automatiques avec détection</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Démo */}
|
||||
<div className="bg-ochre/5 dark:bg-ochre/10 border border-ochre/20 rounded-2xl p-8 flex items-center justify-between group transition-all duration-300 hover:bg-ochre/10">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="p-3 bg-paper dark:bg-ochre/20 rounded-2xl text-ochre border border-ochre/30">
|
||||
<FlaskConical size={20} />
|
||||
</div>
|
||||
<div className="space-y-1.5 text-left">
|
||||
<h4 className="text-sm font-bold text-ink flex items-center gap-3">
|
||||
🧪 Mode Démo
|
||||
</h4>
|
||||
<p className="text-[11px] text-muted-ink leading-relaxed font-medium">Accélère Memory Echo pour les tests. Les connexions apparaissent instantanément.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
|
||||
<input type="checkbox" className="sr-only peer" />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ochre"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const AppearanceSelect = ({ icon, title, description, options, defaultValue }: any) => (
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-slate/5">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border group-hover:scale-110 transition-transform duration-300">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-0.5 text-left">
|
||||
<h4 className="text-base font-bold text-ink">{title}</h4>
|
||||
<p className="text-[11px] text-concrete leading-tight">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative group/select">
|
||||
<select
|
||||
defaultValue={defaultValue}
|
||||
className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-4 text-sm outline-none focus:ring-1 ring-slate/20 appearance-none cursor-pointer text-ink font-bold transition-all hover:bg-white dark:hover:bg-black/60"
|
||||
>
|
||||
{options.map((opt: string) => (
|
||||
<option key={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-concrete group-hover/select:text-slate transition-colors">
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AppearanceTab: React.FC = () => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-16 pb-20"
|
||||
>
|
||||
<div className="space-y-10">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">Personnaliser l'apparence de l'application</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<AppearanceSelect
|
||||
icon={<Palette size={20} />}
|
||||
title="Thème"
|
||||
description="Sélectionner le mode visuel"
|
||||
options={['Clair', 'Sombre', 'Système']}
|
||||
defaultValue="Clair"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<Type size={20} />}
|
||||
title="Taille de la police"
|
||||
description="Ajustez la lisibilité globale de l'interface"
|
||||
options={['Petite', 'Moyenne', 'Grande']}
|
||||
defaultValue="Moyenne"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<Type size={20} />}
|
||||
title="Famille de polices"
|
||||
description="La typographie définit l'âme de l'application"
|
||||
options={['Inter', 'JetBrains Mono', 'Public Sans', 'Outfit']}
|
||||
defaultValue="JetBrains Mono"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<LayoutGrid size={20} />}
|
||||
title="Affichage des notes"
|
||||
description="Gestion visuelle de la grille de composition"
|
||||
options={['Cartes (grille)', 'Liste', 'Tableau']}
|
||||
defaultValue="Cartes (grille)"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<Maximize size={20} />}
|
||||
title="Taille des notes"
|
||||
description="Structure de la mise en page des éléments"
|
||||
options={['Taille uniforme', 'Variable (Masonry)']}
|
||||
defaultValue="Taille uniforme"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
82
architectural-grid11/src/components/settings/GeneralTab.tsx
Normal file
82
architectural-grid11/src/components/settings/GeneralTab.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { Globe, Bell } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
export const GeneralTab: React.FC = () => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-12"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">Paramètres généraux de l'application</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Langue */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border">
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-base font-bold text-ink">Langue</h4>
|
||||
<p className="text-[11px] text-concrete">Sélectionner une langue</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<select className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-3.5 text-sm outline-none focus:ring-1 ring-blueprint/20 appearance-none cursor-pointer transition-all hover:bg-white dark:hover:bg-black/60 text-ink font-medium">
|
||||
<option>Français</option>
|
||||
<option>English</option>
|
||||
<option>Español</option>
|
||||
</select>
|
||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none opacity-40 text-concrete">
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border">
|
||||
<Bell size={18} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-base font-bold text-ink">Notifications</h4>
|
||||
<p className="text-[11px] text-concrete">Gérez vos préférences de notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 divide-y divide-border/40 text-left">
|
||||
<div className="flex items-center justify-between pt-0">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">Notifications par email</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">Recevoir des notifications importantes par email</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-slate"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">Notifications bureau</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">Recevoir des notifications dans votre navigateur</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-slate"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Settings, Sparkles, Palette, User, Database, Code, Info } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { SettingsTab } from '../../types';
|
||||
|
||||
interface SettingsHeaderProps {
|
||||
activeTab: SettingsTab;
|
||||
setActiveTab: (tab: SettingsTab) => void;
|
||||
}
|
||||
|
||||
export const SettingsHeader: React.FC<SettingsHeaderProps> = ({ activeTab, setActiveTab }) => {
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'Paramètres généraux', icon: <Settings size={14} /> },
|
||||
{ id: 'ai', label: 'Paramètres IA', icon: <Sparkles size={14} /> },
|
||||
{ id: 'appearance', label: 'Apparence', icon: <Palette size={14} /> },
|
||||
{ id: 'profile', label: 'Profil', icon: <User size={14} /> },
|
||||
{ id: 'data', label: 'Gestion des données', icon: <Database size={14} /> },
|
||||
{ id: 'mcp', label: 'Paramètres MCP', icon: <Code size={14} /> },
|
||||
{ id: 'about', label: 'À propos', icon: <Info size={14} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="px-12 pt-20 pb-16 space-y-12">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">Paramètres</h1>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">Configuration & Préférences</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-1 border-b border-border/40 pb-px">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as SettingsTab)}
|
||||
className={`flex items-center gap-2.5 px-6 py-5 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap
|
||||
${activeTab === tab.id ? 'text-ink' : 'text-concrete hover:text-ink/60'}`}
|
||||
>
|
||||
<span className={activeTab === tab.id ? 'text-ink' : 'text-concrete'}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<motion.div
|
||||
layoutId="activeSettingsTabLine"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink"
|
||||
transition={{ type: 'spring', bounce: 0.1, duration: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
93
architectural-grid11/src/constants.ts
Normal file
93
architectural-grid11/src/constants.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Carnet, Note } from './types';
|
||||
|
||||
export 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' },
|
||||
{ id: '5', name: 'History of Architecture', initial: 'H', type: 'Project', parentId: '4' },
|
||||
{ id: '6', name: 'Modernism', initial: 'M', type: 'Project', parentId: '5' },
|
||||
{ id: '7', name: 'Sustainable Design', initial: 'S', type: 'Project', parentId: '4' },
|
||||
];
|
||||
|
||||
export const ALL_NOTES: Note[] = [
|
||||
{
|
||||
id: 'n1',
|
||||
carnetId: '4',
|
||||
title: 'Grid Systems & Geometry',
|
||||
date: 'Oct 26, 2024',
|
||||
content: 'Grid Systems are the foundation of cognitive design. We use geometric blocks to define spaces. The repetitive structure creates a sense of order and rhythm in the built environment.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [
|
||||
{ id: 't1', label: 'Architecture', type: 'user' },
|
||||
{ id: 't2', label: 'Systems', type: 'ai' }
|
||||
],
|
||||
embedding: [0.1, 0.1]
|
||||
},
|
||||
{
|
||||
id: 'n1-b',
|
||||
carnetId: '4',
|
||||
title: 'Parametric Grids',
|
||||
date: 'Oct 27, 2024',
|
||||
content: 'Parametricism allows us to deform traditional grid systems. By using mathematical algorithms, we can create fluid yet structured geometries that respond to environmental data.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1511225070737-5af5ac9a690d?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't1', label: 'Geometry', type: 'user' }],
|
||||
embedding: [0.12, 0.08]
|
||||
},
|
||||
{
|
||||
id: 'n2',
|
||||
carnetId: '4',
|
||||
title: 'Sustainable Materiality',
|
||||
date: 'Oct 24, 2024',
|
||||
content: 'Exploring cross-laminated timber (CLT) as a sustainable alternative to concrete. Material choice is key to carbon-neutral construction. The warmth of wood contrasts with the coldness of steel.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [
|
||||
{ id: 't3', label: 'Materials', type: 'user' },
|
||||
{ id: 't4', label: 'Sustainabilty', type: 'ai' }
|
||||
],
|
||||
embedding: [0.8, 0.8]
|
||||
},
|
||||
{
|
||||
id: 'n2-b',
|
||||
carnetId: '7',
|
||||
title: 'Solar Passive Design',
|
||||
date: 'Oct 25, 2024',
|
||||
content: 'Using orientation to maximize natural heat. Sustainable architecture must prioritize passive systems over active ones. Thermal mass and insulation are critical factors.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1509391366360-fe5bb5843e0c?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't4', label: 'Sustainabilty', type: 'user' }],
|
||||
embedding: [0.85, 0.75]
|
||||
},
|
||||
{
|
||||
id: 'n3',
|
||||
carnetId: '4',
|
||||
title: 'Light & Minimalist Space',
|
||||
date: 'Oct 22, 2024',
|
||||
content: 'Minimalism is about the subtraction of the unnecessary. Light becomes a material in itself. Reflections on glass and white surfaces create depth without clutter.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [
|
||||
{ id: 't5', label: 'Lighting', type: 'user' },
|
||||
{ id: 't6', label: 'Atmosphere', type: 'ai' }
|
||||
],
|
||||
embedding: [0.2, 0.8]
|
||||
},
|
||||
{
|
||||
id: 'n3-b',
|
||||
carnetId: '6',
|
||||
title: 'The Glass House Study',
|
||||
date: 'Oct 23, 2024',
|
||||
content: 'Analyzing the transparency of the Glass House. The boundary between interior and exterior is blurred. A pure expression of modernist ideals and minimal structure.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1464938050520-ef2270bb8ce8?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't6', label: 'Modernism', type: 'user' }],
|
||||
embedding: [0.25, 0.85]
|
||||
},
|
||||
{
|
||||
id: 'bridge-1',
|
||||
carnetId: '4',
|
||||
title: 'Geometric Ecology',
|
||||
date: 'Oct 28, 2024',
|
||||
content: 'Can we use grid systems to optimize sustainable solar collection? This note bridges the gap between rigid geometry and ecological necessity. Structured sustainability.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1464146072230-91cabc968276?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't1', label: 'Bridge', type: 'ai' }],
|
||||
embedding: [0.45, 0.45] // Center point
|
||||
}
|
||||
];
|
||||
98
architectural-grid11/src/index.css
Normal file
98
architectural-grid11/src/index.css
Normal file
@@ -0,0 +1,98 @@
|
||||
@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;
|
||||
|
||||
/* Foundation */
|
||||
--color-paper: #F2F0E9;
|
||||
--color-ink: #1C1C1C;
|
||||
--color-muted-ink: rgba(28, 28, 28, 0.6);
|
||||
--color-border: rgba(28, 28, 28, 0.1);
|
||||
--color-concrete: #8D8D8D;
|
||||
|
||||
/* Architectural Accents */
|
||||
--color-blueprint: #75B2D6;
|
||||
--color-slate: #4A4E69;
|
||||
--color-ochre: #D4A373;
|
||||
--color-sage: #A3B18A;
|
||||
--color-rust: #9B2226;
|
||||
--color-glass: rgba(255, 255, 255, 0.4);
|
||||
|
||||
/* Dark Theme Aliases */
|
||||
--color-dark-paper: #0D0D0D;
|
||||
--color-dark-ink: #EAEAEA;
|
||||
--color-dark-muted: rgba(234, 234, 234, 0.5);
|
||||
--color-dark-border: rgba(234, 234, 234, 0.1);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-paper text-ink font-sans antialiased transition-colors duration-500;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
@apply bg-dark-paper;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-paper: #121212;
|
||||
--color-ink: #EAEAEA;
|
||||
--color-muted-ink: rgba(234, 234, 234, 0.6);
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-glass: rgba(0, 0, 0, 0.4);
|
||||
--color-concrete: #555555;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.dark .ai-glass {
|
||||
background: rgba(30, 30, 30, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.dark .active-nav-item {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
10
architectural-grid11/src/main.tsx
Normal file
10
architectural-grid11/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>,
|
||||
);
|
||||
228
architectural-grid11/src/services/clusteringService.ts
Normal file
228
architectural-grid11/src/services/clusteringService.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
|
||||
import { Note, NoteCluster, BridgeNote } from '../types';
|
||||
import { cosineSimilarity } from './geminiService';
|
||||
|
||||
export function dbscan(notes: Note[], eps: number, minPts: number): number[] {
|
||||
const n = notes.length;
|
||||
const labels = new Array(n).fill(-1); // -1 = noise, 0+ = cluster id
|
||||
let clusterId = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (labels[i] !== -1) continue;
|
||||
|
||||
const neighbors = getNeighbors(i, notes, eps);
|
||||
|
||||
if (neighbors.length < minPts) {
|
||||
labels[i] = -1; // remains noise for now
|
||||
continue;
|
||||
}
|
||||
|
||||
labels[i] = clusterId;
|
||||
const queue = neighbors.filter(idx => idx !== i);
|
||||
|
||||
for (let j = 0; j < queue.length; j++) {
|
||||
const pIdx = queue[j];
|
||||
|
||||
if (labels[pIdx] === -1) {
|
||||
labels[pIdx] = clusterId; // noisy point becomes border point
|
||||
}
|
||||
|
||||
if (labels[pIdx] !== -1 && labels[pIdx] < clusterId) {
|
||||
// This should not happen in standard DBSCAN unless we re-visit
|
||||
}
|
||||
|
||||
if (labels[pIdx] === clusterId && labels[pIdx] !== -1) {
|
||||
// Skip if already processed in this cluster
|
||||
}
|
||||
|
||||
// If it was already labeled, skip re-neighboring
|
||||
const pWasNoise = labels[pIdx] === -1;
|
||||
if (labels[pIdx] === -1) labels[pIdx] = clusterId;
|
||||
|
||||
// If point was not processed
|
||||
if (pWasNoise || labels[pIdx] === clusterId ) {
|
||||
// This is a simplified queue processing
|
||||
}
|
||||
}
|
||||
|
||||
// Standard DBSCAN expansion
|
||||
expandCluster(i, neighbors, labels, clusterId, notes, eps, minPts);
|
||||
clusterId++;
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
function expandCluster(pIdx: number, neighbors: number[], labels: number[], clusterId: number, notes: Note[], eps: number, minPts: number) {
|
||||
let i = 0;
|
||||
while (i < neighbors.length) {
|
||||
const qIdx = neighbors[i];
|
||||
if (labels[qIdx] === -1) {
|
||||
labels[qIdx] = clusterId;
|
||||
} else if (labels[qIdx] === undefined || labels[qIdx] === -1) {
|
||||
// unreachable
|
||||
}
|
||||
|
||||
if (labels[qIdx] === clusterId || labels[qIdx] === -1) {
|
||||
const qNeighbors = getNeighbors(qIdx, notes, eps);
|
||||
if (qNeighbors.length >= minPts) {
|
||||
for(const qn of qNeighbors) {
|
||||
if (labels[qn] === -1) {
|
||||
labels[qn] = clusterId;
|
||||
neighbors.push(qn);
|
||||
} else if (!labels.hasOwnProperty(qn)) {
|
||||
// logic error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean DBSCAN implementation
|
||||
export function runClustering(notes: Note[], eps: number = 0.15, minPts: number = 2): { labels: number[], clusters: NoteCluster[] } {
|
||||
const validNotes = notes.filter(n => n.embedding && n.embedding.length > 0);
|
||||
if (validNotes.length === 0) return { labels: [], clusters: [] };
|
||||
|
||||
const n = validNotes.length;
|
||||
const labels = new Array(n).fill(-1);
|
||||
let cId = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (labels[i] !== -1) continue;
|
||||
|
||||
const neighbors = findNeighbors(i, validNotes, eps);
|
||||
if (neighbors.length < minPts) {
|
||||
labels[i] = -1;
|
||||
} else {
|
||||
labels[i] = cId;
|
||||
expand(i, neighbors, labels, cId, validNotes, eps, minPts);
|
||||
cId++;
|
||||
}
|
||||
}
|
||||
|
||||
const clusters: NoteCluster[] = [];
|
||||
const colorPalette = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF'];
|
||||
|
||||
for (let i = 0; i < cId; i++) {
|
||||
const noteIds = validNotes.filter((_, idx) => labels[idx] === i).map(n => n.id);
|
||||
clusters.push({
|
||||
id: `cluster-${i}`,
|
||||
name: `Cluster ${i + 1}`,
|
||||
noteIds,
|
||||
color: colorPalette[i % colorPalette.length]
|
||||
});
|
||||
}
|
||||
|
||||
return { labels, clusters };
|
||||
}
|
||||
|
||||
function findNeighbors(idx: number, notes: Note[], eps: number): number[] {
|
||||
const neighbors: number[] = [];
|
||||
const targetEmbedding = notes[idx].embedding!;
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
const sim = cosineSimilarity(targetEmbedding, notes[i].embedding!);
|
||||
const dist = 1 - sim;
|
||||
if (dist <= eps) {
|
||||
neighbors.push(i);
|
||||
}
|
||||
}
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
function expand(rootIdx: number, neighbors: number[], labels: number[], cId: number, notes: Note[], eps: number, minPts: number) {
|
||||
const queue = [...neighbors];
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const qIdx = queue[i];
|
||||
if (labels[qIdx] === -1) {
|
||||
labels[qIdx] = cId;
|
||||
}
|
||||
if (labels[qIdx] !== -1 && labels[qIdx] !== cId) continue;
|
||||
if (labels[qIdx] === cId) {
|
||||
// already visited but let's check neighbors if we just added it
|
||||
}
|
||||
|
||||
// If point was noise, it now belongs to cluster, but we don't necessarily expand from it unless it's a core point
|
||||
// This is the standard DBSCAN: noise points can become border points
|
||||
}
|
||||
|
||||
// Re-implementing correctly
|
||||
let head = 0;
|
||||
while(head < queue.length) {
|
||||
const qIdx = queue[head];
|
||||
if (labels[qIdx] === -1) labels[qIdx] = cId;
|
||||
if (labels[qIdx] === cId) {
|
||||
const qNeighbors = findNeighbors(qIdx, notes, eps);
|
||||
if (qNeighbors.length >= minPts) {
|
||||
for(const qn of qNeighbors) {
|
||||
if (labels[qn] === -1) {
|
||||
labels[qn] = cId;
|
||||
queue.push(qn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
head++;
|
||||
}
|
||||
}
|
||||
|
||||
function getNeighbors(idx: number, notes: Note[], eps: number): number[] {
|
||||
const neighbors: number[] = [];
|
||||
const target = notes[idx].embedding!;
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
if (!notes[i].embedding) continue;
|
||||
const dist = 1 - cosineSimilarity(target, notes[i].embedding!);
|
||||
if (dist <= eps) neighbors.push(i);
|
||||
}
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
export function detectBridges(notes: Note[], clusters: NoteCluster[], threshold: number = 0.5): BridgeNote[] {
|
||||
const bridges: BridgeNote[] = [];
|
||||
const validNotes = notes.filter(n => n.embedding);
|
||||
|
||||
for (const note of validNotes) {
|
||||
const connectedClusters = new Set<string>();
|
||||
|
||||
for (const cluster of clusters) {
|
||||
// Check if note has strong links to ANY note in this cluster
|
||||
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id) && n.embedding);
|
||||
const hasStrongLink = clusterNotes.some(cn => cosineSimilarity(note.embedding!, cn.embedding!) > threshold);
|
||||
|
||||
if (hasStrongLink) {
|
||||
connectedClusters.add(cluster.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedClusters.size >= 2) {
|
||||
bridges.push({
|
||||
noteId: note.id,
|
||||
connectedClusterIds: Array.from(connectedClusters),
|
||||
bridgeScore: connectedClusters.size / Math.max(clusters.length, 1)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return bridges.sort((a, b) => b.bridgeScore - a.bridgeScore);
|
||||
}
|
||||
|
||||
export function calculateCentroid(noteIds: string[], allNotes: Note[]): number[] | undefined {
|
||||
const clusterNotes = allNotes.filter(n => noteIds.includes(n.id) && n.embedding);
|
||||
if (clusterNotes.length === 0) return undefined;
|
||||
|
||||
const embeddingDim = clusterNotes[0].embedding!.length;
|
||||
const centroid = new Array(embeddingDim).fill(0);
|
||||
|
||||
for (const note of clusterNotes) {
|
||||
for (let i = 0; i < embeddingDim; i++) {
|
||||
centroid[i] += note.embedding![i];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < embeddingDim; i++) {
|
||||
centroid[i] /= clusterNotes.length;
|
||||
}
|
||||
|
||||
return centroid;
|
||||
}
|
||||
200
architectural-grid11/src/services/geminiService.ts
Normal file
200
architectural-grid11/src/services/geminiService.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { BrainstormIdea } from "../types";
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
|
||||
const BRAINSTORM_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
ideas: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
title: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
connection_to_seed: { type: Type.STRING },
|
||||
novelty_score: { type: Type.NUMBER }
|
||||
},
|
||||
required: ["title", "description", "connection_to_seed", "novelty_score"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["ideas"]
|
||||
};
|
||||
|
||||
const SUGGESTIONS_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
suggestions: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
title: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
reasoning: { type: Type.STRING }
|
||||
},
|
||||
required: ["title", "description", "reasoning"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["suggestions"]
|
||||
};
|
||||
|
||||
export async function generateBrainstormWave(
|
||||
seedIdea: string,
|
||||
waveNumber: number,
|
||||
contextSummaries: string = ""
|
||||
): Promise<Partial<BrainstormIdea>[]> {
|
||||
const waveDescriptions = [
|
||||
"", // index 0 unused
|
||||
"VAGUE 1 (proximité directe) : Sous-aspects, reformulations, variations de l'idée. Reste dans le même domaine.",
|
||||
"VAGUE 2 (analogies) : Trouve des parallèles dans d'autres domaines. Comment cette idée se manifeste-t-elle ailleurs ? Quelles techniques d'autres industries pourraient s'appliquer ?",
|
||||
"VAGUE 3 (disruption) : Inverse l'idée. Pousse-la à l'extrême. Combine-la avec un domaine totalement non lié. Que se passe-t-il si l'opposé est vrai ?"
|
||||
];
|
||||
|
||||
const prompt = `
|
||||
Idée seed : "${seedIdea}"
|
||||
Contexte : ${contextSummaries}
|
||||
Génère 5 idées pour la VAGUE ${waveNumber} : ${waveDescriptions[waveNumber]}
|
||||
Format JSON selon le schéma.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
systemInstruction: "Tu es un expert en brainstorming. Réponds uniquement en JSON valide.",
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: BRAINSTORM_SCHEMA,
|
||||
temperature: 1.0
|
||||
}
|
||||
});
|
||||
|
||||
const resText = response.text;
|
||||
if (!resText) return [];
|
||||
|
||||
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
|
||||
const ideas = Array.isArray(parsed.ideas) ? parsed.ideas : (Array.isArray(parsed) ? parsed : []);
|
||||
|
||||
return ideas.map((item: any) => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
connectionToSeed: item.connection_to_seed,
|
||||
noveltyScore: item.novelty_score,
|
||||
waveNumber
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Error generating brainstorm wave ${waveNumber}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateExpansion(parentIdeaTitle: string, parentIdeaDescription: string): Promise<Partial<BrainstormIdea>[]> {
|
||||
const prompt = `
|
||||
Idée source : "${parentIdeaTitle} - ${parentIdeaDescription}"
|
||||
Génère 3 idées d'extension ou de sous-aspects.
|
||||
Format JSON.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
systemInstruction: "Tu es un expert en brainstorming. Réponds uniquement en JSON valide.",
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: BRAINSTORM_SCHEMA,
|
||||
temperature: 1.0
|
||||
}
|
||||
});
|
||||
|
||||
const resText = response.text;
|
||||
if (!resText) return [];
|
||||
|
||||
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
|
||||
const ideas = Array.isArray(parsed.ideas) ? parsed.ideas : (Array.isArray(parsed) ? parsed : []);
|
||||
|
||||
return ideas.map((item: any) => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
connectionToSeed: item.connection_to_seed,
|
||||
noveltyScore: item.novelty_score
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error generating expansion:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
const result = await ai.models.embedContent({
|
||||
model: 'gemini-embedding-2-preview',
|
||||
contents: [text],
|
||||
});
|
||||
return result.embeddings[0].values;
|
||||
} catch (error) {
|
||||
console.error("Error generating embedding:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (!a || !b || a.length !== b.length) return 0;
|
||||
const dotProduct = a.reduce((sum, val, i) => sum + val * b[i], 0);
|
||||
const magnitudeA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
|
||||
const magnitudeB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
|
||||
if (magnitudeA === 0 || magnitudeB === 0) return 0;
|
||||
return dotProduct / (magnitudeA * magnitudeB);
|
||||
}
|
||||
|
||||
export async function nameCluster(noteSummaries: string[]): Promise<string> {
|
||||
const prompt = `Quel thème commun relie ces notes ? Donne un nom court (2-4 mots).\nNotes :\n${noteSummaries.join('\n- ')}`;
|
||||
try {
|
||||
const result = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt
|
||||
});
|
||||
return result.text.trim();
|
||||
} catch (error) {
|
||||
console.error("Error naming cluster:", error);
|
||||
return "Thematic Cluster";
|
||||
}
|
||||
}
|
||||
|
||||
export async function suggestBridgeIdeas(
|
||||
clusterAName: string,
|
||||
clusterBName: string,
|
||||
clusterASummaries: string,
|
||||
clusterBSummaries: string
|
||||
): Promise<any[]> {
|
||||
const prompt = `
|
||||
Cluster A (${clusterAName}) contient des notes sur : ${clusterASummaries}
|
||||
Cluster B (${clusterBName}) contient des notes sur : ${clusterBSummaries}
|
||||
|
||||
Ces deux clusters ne sont pas connectés. Propose 3 idées
|
||||
de "notes pont" qui pourraient créer un lien créatif entre eux.
|
||||
Pour chaque idée : titre, description, pourquoi ça connecte les deux.
|
||||
|
||||
Format JSON.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt,
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: SUGGESTIONS_SCHEMA
|
||||
}
|
||||
});
|
||||
return JSON.parse(response.text);
|
||||
} catch (error) {
|
||||
console.error("Error suggesting bridge ideas:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
76
architectural-grid11/src/services/temporalService.ts
Normal file
76
architectural-grid11/src/services/temporalService.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Note, NoteAccessLog, NotePrediction } from '../types';
|
||||
|
||||
/**
|
||||
* Simulates finding the dominant frequency in access logs for a specific note
|
||||
* returning the period in days.
|
||||
*/
|
||||
export function detectAccessCycle(logs: NoteAccessLog[]): number | null {
|
||||
if (logs.length < 5) return null;
|
||||
|
||||
const accessDays = logs
|
||||
.map(log => new Date(log.accessedAt).getTime())
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const intervals: number[] = [];
|
||||
for (let i = 1; i < accessDays.length; i++) {
|
||||
intervals.push((accessDays[i] - accessDays[i - 1]) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// Simple heuristic: if intervals are consistently around a value, that's our cycle
|
||||
// We'll calculate the median interval
|
||||
const sortedIntervals = [...intervals].sort((a, b) => a - b);
|
||||
const median = sortedIntervals[Math.floor(sortedIntervals.length / 2)];
|
||||
|
||||
// Check if enough intervals are close to median
|
||||
const withinThreshold = intervals.filter(v => Math.abs(v - median) < Math.max(2, median * 0.2));
|
||||
|
||||
if (withinThreshold.length >= intervals.length * 0.6) {
|
||||
return median;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function predictNextAccess(note: Note, logs: NoteAccessLog[]): NotePrediction | null {
|
||||
const cycleDays = detectAccessCycle(logs);
|
||||
if (!cycleDays) return null;
|
||||
|
||||
const lastAccess = new Date(logs[logs.length - 1].accessedAt);
|
||||
const nextAccessDate = new Date(lastAccess.getTime() + cycleDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const now = new Date();
|
||||
const daysUntilNext = (nextAccessDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||
|
||||
// Only predict if it's coming up in the next 2 weeks
|
||||
if (daysUntilNext > 0 && daysUntilNext < 14) {
|
||||
return {
|
||||
noteId: note.id,
|
||||
predictedRelevanceDate: nextAccessDate.toISOString(),
|
||||
confidence: 0.7,
|
||||
reason: `Historical access pattern suggests a ${Math.round(cycleDays)}-day cycle.`,
|
||||
generatedAt: now.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getCoaccessedNotes(baseNoteId: string, logs: NoteAccessLog[], allNotes: Note[]): Note[] {
|
||||
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
const baseNoteLogs = logs.filter(l => l.noteId === baseNoteId);
|
||||
const coaccessedIds = new Set<string>();
|
||||
|
||||
baseNoteLogs.forEach(baseLog => {
|
||||
const baseTime = new Date(baseLog.accessedAt).getTime();
|
||||
logs.forEach(otherLog => {
|
||||
if (otherLog.noteId === baseNoteId) return;
|
||||
const otherTime = new Date(otherLog.accessedAt).getTime();
|
||||
if (Math.abs(baseTime - otherTime) < WINDOW_MS) {
|
||||
coaccessedIds.add(otherLog.noteId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return allNotes.filter(n => coaccessedIds.has(n.id));
|
||||
}
|
||||
102
architectural-grid11/src/types.ts
Normal file
102
architectural-grid11/src/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export type NavigationView = 'notebooks' | 'agents' | 'settings' | 'shared' | 'reminders' | 'trash' | 'brainstorm' | 'insights' | 'temporal';
|
||||
export type AITone = 'Professional' | 'Creative' | 'Academic' | 'Casual';
|
||||
export type AITab = 'discussion' | 'actions' | 'resources' | 'explore';
|
||||
export type SettingsTab = 'general' | 'ai' | 'appearance' | 'profile' | 'data' | 'mcp' | 'about';
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'ai' | 'user';
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
carnetId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
imageUrl: string;
|
||||
date: string;
|
||||
tags: Tag[];
|
||||
isPinned?: boolean;
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: string;
|
||||
embedding?: number[];
|
||||
clusterId?: string;
|
||||
}
|
||||
|
||||
export interface NoteCluster {
|
||||
id: string;
|
||||
name: string;
|
||||
noteIds: string[];
|
||||
centroid? : number[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface BridgeNote {
|
||||
noteId: string;
|
||||
connectedClusterIds: string[];
|
||||
bridgeScore: number;
|
||||
}
|
||||
|
||||
export interface ConnectionSuggestion {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reasoning: string;
|
||||
clusterAId: string;
|
||||
clusterBId: string;
|
||||
}
|
||||
|
||||
export interface BrainstormSession {
|
||||
id: string;
|
||||
seedIdea: string;
|
||||
sourceNoteId?: string;
|
||||
contextNoteIds?: string[];
|
||||
exportedNoteId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type BrainstormIdeaStatus = 'active' | 'dismissed' | 'converted';
|
||||
|
||||
export interface BrainstormIdea {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
waveNumber: 1 | 2 | 3;
|
||||
title: string;
|
||||
description: string;
|
||||
connectionToSeed: string;
|
||||
noveltyScore: number; // 1-10
|
||||
parentIdeaId?: string;
|
||||
convertedToNoteId?: string;
|
||||
relatedNoteIds?: string[];
|
||||
status: BrainstormIdeaStatus;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface Carnet {
|
||||
id: string;
|
||||
name: string;
|
||||
initial: string;
|
||||
type: 'Private' | 'Project' | 'Shared';
|
||||
isPrivate?: boolean;
|
||||
parentId?: string;
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
export interface NoteAccessLog {
|
||||
noteId: string;
|
||||
accessedAt: string;
|
||||
action: 'view' | 'edit' | 'search_hit';
|
||||
}
|
||||
|
||||
export interface NotePrediction {
|
||||
noteId: string;
|
||||
predictedRelevanceDate: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
26
architectural-grid11/tsconfig.json
Normal file
26
architectural-grid11/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-grid11/vite.config.ts
Normal file
24
architectural-grid11/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-grid12/.env.example
Normal file
9
architectural-grid12/.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-grid12/.gitignore
vendored
Normal file
8
architectural-grid12/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
24
architectural-grid12/BRAINSTORM_PROMPT.md
Normal file
24
architectural-grid12/BRAINSTORM_PROMPT.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# IA Agent Coordination Prompt: Brainstorm Wave Integration
|
||||
|
||||
## Context
|
||||
You are tasked with continuing the development of the "Architectural Grid" application. The core feature "Wave Brainstorming" has been partially implemented with a full-stack architecture (Express + React).
|
||||
|
||||
## Current State
|
||||
- **Backend (`server.ts`)**: Implements session management, idea generation via Gemini, and expansion logic. Stores data in memory.
|
||||
- **Frontend (`BrainstormView.tsx`)**: Manages the life cycle of a brainstorm. Integrates with a Radial D3 Canvas.
|
||||
- **Visuals (`WaveCanvas.tsx`)**: Implements a radial force-directed graph with state-aware styling (dismissed/converted).
|
||||
- **Navigation**: "Brainstorm Wave" is accessible from the Sidebar. A quick entry point exists from Note Detail view.
|
||||
|
||||
## Your Task: Sidebar & Navigation Cleanup
|
||||
1. **Source Code Review**: Read `src/components/Sidebar.tsx`, `src/App.tsx`, and `server.ts` to understand how views are toggled.
|
||||
2. **Sidebar Links**: Ensure "Brainstorm Wave", "Semantic Network", and "Temporal Forecast" are correctly grouped and labeled in the Sidebar under a "Creative & AI" section.
|
||||
3. **Agent View Sidebar**: The user specifically requested these links to be also accessible from the "Sidebar of the Agent view". Review `src/components/AgentsView.tsx` and ensure it has consistent navigation or deep links to these advanced features.
|
||||
4. **Semantic Network & Temporal Forecast**: These views are currently placeholders. Ensure the routing and sidebar active state detection work correctly for them.
|
||||
|
||||
## Technical Requirements
|
||||
- Maintain consistency with the **Tailwind** architectural design (concrete, paper, blueprint tokens).
|
||||
- Use **Lucide-React** icons (`Wind` for Brainstorm, `Share2` for Semantic Network, `Clock` for Temporal).
|
||||
- Ensure transitions between views are smooth using `motion/react`.
|
||||
|
||||
---
|
||||
*Copy and paste this into the next AI Agent session to ensure full context transfer.*
|
||||
20
architectural-grid12/README.md
Normal file
20
architectural-grid12/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-grid12/index.html
Normal file
13
architectural-grid12/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-grid12/metadata.json
Normal file
6
architectural-grid12/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Architectural Grid",
|
||||
"description": "A minimalist notebook for architectural research and conceptual sketches, featuring a Wave Brainstorming radial canvas for AI-powered idea exploration.",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": []
|
||||
}
|
||||
5508
architectural-grid12/package-lock.json
generated
Normal file
5508
architectural-grid12/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
architectural-grid12/package.json
Normal file
41
architectural-grid12/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx server.ts",
|
||||
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
|
||||
"start": "node dist/server.cjs",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.29.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"d3": "^7.9.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "^6.2.3",
|
||||
"ws": "^8.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
166
architectural-grid12/server.ts
Normal file
166
architectural-grid12/server.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { createServer as createViteServer } from "vite";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { createServer } from "http";
|
||||
|
||||
interface BrainstormIdea {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
waveNumber: number;
|
||||
title: string;
|
||||
description: string;
|
||||
connectionToSeed: string;
|
||||
noveltyScore: number;
|
||||
parentIdeaId?: string;
|
||||
convertedToNoteId?: string;
|
||||
status: 'active' | 'dismissed' | 'converted';
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface BrainstormSession {
|
||||
id: string;
|
||||
seedIdea: string;
|
||||
sourceNoteId?: string;
|
||||
contextNoteIds?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// In-memory store
|
||||
const sessions: BrainstormSession[] = [];
|
||||
const ideas: BrainstormIdea[] = [];
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
const PORT = 3000;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// WebSocket logic
|
||||
const rooms = new Map<string, Set<WebSocket>>();
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
let currentRoom: string | null = null;
|
||||
|
||||
ws.on('message', (message) => {
|
||||
const data = JSON.parse(message.toString());
|
||||
|
||||
if (data.type === 'join') {
|
||||
const { sessionId } = data;
|
||||
currentRoom = sessionId;
|
||||
if (!rooms.has(sessionId)) rooms.set(sessionId, new Set());
|
||||
rooms.get(sessionId)!.add(ws);
|
||||
console.log(`User joined session: ${sessionId}`);
|
||||
}
|
||||
|
||||
if (data.type === 'idea_added' || data.type === 'idea_updated' || data.type === 'activity') {
|
||||
if (currentRoom && rooms.has(currentRoom)) {
|
||||
rooms.get(currentRoom)!.forEach(client => {
|
||||
if (client !== ws && client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(data));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (currentRoom && rooms.has(currentRoom)) {
|
||||
rooms.get(currentRoom)!.delete(ws);
|
||||
if (rooms.get(currentRoom)!.size === 0) rooms.delete(currentRoom);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.get("/api/health", (req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// 1. Create session
|
||||
app.post("/api/brainstorm/sessions", (req, res) => {
|
||||
const { seedIdea, sourceNoteId, contextNoteIds } = req.body;
|
||||
|
||||
const session: BrainstormSession = {
|
||||
id: uuidv4(),
|
||||
seedIdea,
|
||||
sourceNoteId,
|
||||
contextNoteIds,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
sessions.unshift(session);
|
||||
res.json(session);
|
||||
});
|
||||
|
||||
// 2. Add ideas to session
|
||||
app.post("/api/brainstorm/:sessionId/ideas", (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const { ideas: newIdeasData } = req.body;
|
||||
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
if (!session) return res.status(404).json({ error: "Session not found" });
|
||||
|
||||
const newIdeas = newIdeasData.map((item: any) => ({
|
||||
id: item.id || uuidv4(),
|
||||
sessionId,
|
||||
waveNumber: item.waveNumber,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
connectionToSeed: item.connectionToSeed,
|
||||
noveltyScore: item.noveltyScore,
|
||||
parentIdeaId: item.parentIdeaId,
|
||||
status: 'active'
|
||||
}));
|
||||
|
||||
newIdeas.forEach((i: any) => ideas.push(i));
|
||||
res.json(newIdeas);
|
||||
});
|
||||
|
||||
// 3. Get all sessions
|
||||
app.get("/api/brainstorm/sessions", (req, res) => {
|
||||
res.json(sessions);
|
||||
});
|
||||
|
||||
// 4. Get session with ideas
|
||||
app.get("/api/brainstorm/:sessionId", (req, res) => {
|
||||
const session = sessions.find(s => s.id === req.params.sessionId);
|
||||
if (!session) return res.status(404).json({ error: "Session not found" });
|
||||
const sessionIdeas = ideas.filter(i => i.sessionId === session.id);
|
||||
res.json({ session, ideas: sessionIdeas });
|
||||
});
|
||||
|
||||
// 5. Update idea (position, status)
|
||||
app.patch("/api/brainstorm/ideas/:ideaId", (req, res) => {
|
||||
const index = ideas.findIndex(i => i.id === req.params.ideaId);
|
||||
if (index === -1) return res.status(404).json({ error: "Idea not found" });
|
||||
|
||||
ideas[index] = { ...ideas[index], ...req.body };
|
||||
res.json(ideas[index]);
|
||||
});
|
||||
|
||||
// Vite middleware for development
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const vite = await createViteServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "spa",
|
||||
});
|
||||
app.use(vite.middlewares);
|
||||
} else {
|
||||
const distPath = path.join(process.cwd(), 'dist');
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
612
architectural-grid12/src/App.tsx
Normal file
612
architectural-grid12/src/App.tsx
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
// Components
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { NotebooksView } from './components/NotebooksView';
|
||||
import { AgentsView } from './components/AgentsView';
|
||||
import { SettingsView } from './components/SettingsView';
|
||||
import { TrashView } from './components/TrashView';
|
||||
import { BrainstormView } from './components/BrainstormView/BrainstormView';
|
||||
import { InsightsView } from './components/InsightsView';
|
||||
import { TemporalView } from './components/TemporalView';
|
||||
import { AISidebar } from './components/AISidebar';
|
||||
import { SlashMenu } from './components/SlashMenu';
|
||||
|
||||
// Data & Types
|
||||
import { CARNETS, ALL_NOTES } from './constants';
|
||||
import { NavigationView, SettingsTab, AITab, AITone, Carnet, Note, BrainstormIdea, NoteAccessLog } from './types';
|
||||
|
||||
export default function App() {
|
||||
const [activeView, setActiveView] = useState<NavigationView>('notebooks');
|
||||
const [activeSettingsTab, setActiveSettingsTab] = useState<SettingsTab>('general');
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [carnets, setCarnets] = useState<Carnet[]>(CARNETS);
|
||||
const [notes, setNotes] = useState<Note[]>(ALL_NOTES);
|
||||
const [accessLogs, setAccessLogs] = useState<NoteAccessLog[]>([
|
||||
// Note n1: 14-day cycle
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 70 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 56 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 42 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
|
||||
// Note n2: 7-day cycle
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
|
||||
// Note n3: 3-day cycle (frequent check)
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 12 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 9 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
]);
|
||||
|
||||
const logNoteAccess = (noteId: string, action: 'view' | 'edit' | 'search_hit' = 'view') => {
|
||||
const newLog: NoteAccessLog = {
|
||||
noteId,
|
||||
accessedAt: new Date().toISOString(),
|
||||
action
|
||||
};
|
||||
setAccessLogs(prev => [...prev, newLog]);
|
||||
};
|
||||
|
||||
const [activeCarnetId, setActiveCarnetId] = useState('4');
|
||||
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
|
||||
const [brainstormSeed, setBrainstormSeed] = useState<string | null>(null);
|
||||
|
||||
const handleBrainstormNote = (note: Note) => {
|
||||
setActiveView('brainstorm');
|
||||
// We'll use a small delay or a ref to pass this to BrainstormView if needed,
|
||||
// but better to just share state or use a CustomEvent
|
||||
window.dispatchEvent(new CustomEvent('start-brainstorm', {
|
||||
detail: { seed: note.title, sourceNoteId: note.id }
|
||||
}));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeNoteId) {
|
||||
logNoteAccess(activeNoteId);
|
||||
}
|
||||
}, [activeNoteId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Check for session in URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const session = params.get('session');
|
||||
if (session) {
|
||||
setActiveView('brainstorm');
|
||||
// We pass it via a global property or custom event since BrainstormView will fetch sessions
|
||||
(window as any).initialSessionId = session;
|
||||
}
|
||||
|
||||
const handleSwitchView = (e: any) => {
|
||||
if (e.detail) {
|
||||
setActiveView(e.detail as NavigationView);
|
||||
}
|
||||
};
|
||||
window.addEventListener('switch-view', handleSwitchView);
|
||||
return () => window.removeEventListener('switch-view', handleSwitchView);
|
||||
}, []);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const [isAISidebarOpen, setIsAISidebarOpen] = useState(false);
|
||||
const [aiTab, setAiTab] = useState<AITab>('discussion');
|
||||
const [selectedTone, setSelectedTone] = useState<AITone>('Professional');
|
||||
|
||||
// Modal States
|
||||
const [showNewCarnetModal, setShowNewCarnetModal] = useState<{ isOpen: boolean; parentId?: string; isRenaming?: boolean; carnetId?: string }>({ isOpen: false });
|
||||
const [showNewNoteModal, setShowNewNoteModal] = useState(false);
|
||||
const [slashMenu, setSlashMenu] = useState<{ isOpen: boolean; top: number; left: number } | null>(null);
|
||||
|
||||
// Form States
|
||||
const [newCarnetName, setNewCarnetName] = useState('');
|
||||
const [newNoteTitle, setNewNoteTitle] = useState('');
|
||||
const [newNoteContent, setNewNoteContent] = useState('');
|
||||
|
||||
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === '/') {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
setSlashMenu({
|
||||
isOpen: true,
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const togglePin = (noteId: string) => {
|
||||
setNotes(notes.map(n => n.id === noteId ? { ...n, isPinned: !n.isPinned } : n));
|
||||
};
|
||||
|
||||
const filteredNotes = useMemo(() => {
|
||||
let result = notes.filter(n => n.carnetId === activeCarnetId && !n.isDeleted);
|
||||
|
||||
if (selectedTagIds.length > 0) {
|
||||
result = result.filter(note =>
|
||||
selectedTagIds.every(tagId => note.tags?.some(tag => tag.id === tagId))
|
||||
);
|
||||
}
|
||||
|
||||
return [...result].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [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;
|
||||
|
||||
if (showNewCarnetModal.isRenaming && showNewCarnetModal.carnetId) {
|
||||
setCarnets(carnets.map(c => c.id === showNewCarnetModal.carnetId ? { ...c, name: newCarnetName, initial: newCarnetName.charAt(0).toUpperCase() } : c));
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setNewCarnetName('');
|
||||
return;
|
||||
}
|
||||
|
||||
const newCarnet: Carnet = {
|
||||
id: Date.now().toString(),
|
||||
name: newCarnetName,
|
||||
initial: newCarnetName.charAt(0).toUpperCase(),
|
||||
type: 'Project',
|
||||
parentId: showNewCarnetModal.parentId
|
||||
};
|
||||
|
||||
setCarnets([...carnets, newCarnet]);
|
||||
setNewCarnetName('');
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setActiveCarnetId(newCarnet.id);
|
||||
};
|
||||
|
||||
const handleDeleteCarnet = (id: string) => {
|
||||
if (window.confirm('Déplacer ce carnet et ses sous-carnets vers la corbeille ?')) {
|
||||
const idsToDelete = new Set<string>([id]);
|
||||
|
||||
const addChildren = (parentId: string) => {
|
||||
carnets.forEach(c => {
|
||||
if (c.parentId === parentId) {
|
||||
idsToDelete.add(c.id);
|
||||
addChildren(c.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
addChildren(id);
|
||||
|
||||
const deletedAt = new Date().toISOString();
|
||||
setCarnets(carnets.map(c => idsToDelete.has(c.id) ? { ...c, isDeleted: true, deletedAt } : c));
|
||||
setNotes(notes.map(n => idsToDelete.has(n.carnetId) ? { ...n, isDeleted: true, deletedAt } : n));
|
||||
|
||||
if (idsToDelete.has(activeCarnetId)) {
|
||||
setActiveCarnetId('1');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNote = (id: string) => {
|
||||
const deletedAt = new Date().toISOString();
|
||||
setNotes(notes.map(n => n.id === id ? { ...n, isDeleted: true, deletedAt } : n));
|
||||
if (activeNoteId === id) setActiveNoteId(null);
|
||||
};
|
||||
|
||||
const handleRestoreCarnet = (id: string) => {
|
||||
setCarnets(carnets.map(c => c.id === id ? { ...c, isDeleted: false, deletedAt: undefined } : c));
|
||||
// Optionally restore linked notes too? User might expect that.
|
||||
setNotes(notes.map(n => n.carnetId === id ? { ...n, isDeleted: false, deletedAt: undefined } : n));
|
||||
};
|
||||
|
||||
const handleRestoreNote = (id: string) => {
|
||||
setNotes(notes.map(n => n.id === id ? { ...n, isDeleted: false, deletedAt: undefined } : n));
|
||||
};
|
||||
|
||||
const handlePermanentDeleteNote = (id: string) => {
|
||||
setNotes(notes.filter(n => n.id !== id));
|
||||
};
|
||||
|
||||
const handlePermanentDeleteCarnet = (id: string) => {
|
||||
const idsToDelete = new Set<string>([id]);
|
||||
const addChildren = (parentId: string) => {
|
||||
carnets.forEach(c => {
|
||||
if (c.parentId === parentId) {
|
||||
idsToDelete.add(c.id);
|
||||
addChildren(c.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
addChildren(id);
|
||||
setCarnets(carnets.filter(c => !idsToDelete.has(c.id)));
|
||||
setNotes(notes.filter(n => !idsToDelete.has(n.carnetId)));
|
||||
};
|
||||
|
||||
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',
|
||||
tags: []
|
||||
};
|
||||
|
||||
setNotes([newNote, ...notes]);
|
||||
setNewNoteTitle('');
|
||||
setNewNoteContent('');
|
||||
setShowNewNoteModal(false);
|
||||
setActiveNoteId(newNote.id);
|
||||
};
|
||||
|
||||
const handleConvertIdeaToNote = (idea: BrainstormIdea) => {
|
||||
const newNote: Note = {
|
||||
id: `n-gen-${Date.now()}`,
|
||||
carnetId: activeCarnetId,
|
||||
title: idea.title,
|
||||
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
|
||||
content: `${idea.description}\n\n---\n**Connection to seed:** ${idea.connectionToSeed}\n**Novelty Score:** ${idea.noveltyScore}/10`,
|
||||
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't-ai', label: 'AI Generated', type: 'ai' }]
|
||||
};
|
||||
|
||||
setNotes([newNote, ...notes]);
|
||||
setActiveView('notebooks');
|
||||
setActiveNoteId(newNote.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`h-screen flex bg-paper transition-colors duration-500 overflow-hidden font-sans ${isDarkMode ? 'dark' : ''}`}>
|
||||
<Sidebar
|
||||
activeView={activeView}
|
||||
isDarkMode={isDarkMode}
|
||||
setIsDarkMode={setIsDarkMode}
|
||||
setActiveView={setActiveView}
|
||||
carnets={carnets}
|
||||
notes={notes}
|
||||
activeCarnetId={activeCarnetId}
|
||||
activeNoteId={activeNoteId}
|
||||
setActiveCarnetId={setActiveCarnetId}
|
||||
setActiveNoteId={setActiveNoteId}
|
||||
setShowNewCarnetModal={(show, parentId, isRenaming, carnetId) => {
|
||||
setShowNewCarnetModal({ isOpen: show, parentId, isRenaming, carnetId });
|
||||
if (isRenaming && carnetId) {
|
||||
const carnet = carnets.find(c => c.id === carnetId);
|
||||
if (carnet) setNewCarnetName(carnet.name);
|
||||
} else {
|
||||
setNewCarnetName('');
|
||||
}
|
||||
}}
|
||||
onDeleteCarnet={handleDeleteCarnet}
|
||||
onMoveCarnet={(draggedId, targetId) => {
|
||||
if (draggedId === targetId) return;
|
||||
|
||||
// Basic circular check
|
||||
const isDescendant = (parentId: string, potentialChildId: string): boolean => {
|
||||
const childIds = carnets.filter(c => c.parentId === parentId).map(c => c.id);
|
||||
if (childIds.includes(potentialChildId)) return true;
|
||||
return childIds.some(id => isDescendant(id, potentialChildId));
|
||||
};
|
||||
|
||||
if (targetId && isDescendant(draggedId, targetId)) {
|
||||
console.warn("Cannot move a notebook inside its own descendant");
|
||||
return;
|
||||
}
|
||||
|
||||
setCarnets(prev => prev.map(c => c.id === draggedId ? { ...c, parentId: targetId } : c));
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="flex-1 relative overflow-hidden flex bg-paper dark:bg-dark-paper transition-colors duration-500">
|
||||
<AnimatePresence mode="wait">
|
||||
{(activeView === 'notebooks' || activeView === 'shared' || activeView === 'reminders') && (
|
||||
<motion.div
|
||||
key={activeView}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<NotebooksView
|
||||
activeNoteId={activeNoteId}
|
||||
activeCarnet={activeCarnet}
|
||||
filteredNotes={filteredNotes}
|
||||
activeNote={activeNote}
|
||||
setActiveNoteId={setActiveNoteId}
|
||||
togglePin={togglePin}
|
||||
setShowNewNoteModal={setShowNewNoteModal}
|
||||
isAISidebarOpen={isAISidebarOpen}
|
||||
setIsAISidebarOpen={setIsAISidebarOpen}
|
||||
selectedTagIds={selectedTagIds}
|
||||
setSelectedTagIds={setSelectedTagIds}
|
||||
allNotes={notes}
|
||||
activeCarnetId={activeCarnetId}
|
||||
setShowNewCarnetModal={(show, parentId, isRenaming, carnetId) => setShowNewCarnetModal({ isOpen: show, parentId, isRenaming, carnetId })}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
onBrainstormNote={handleBrainstormNote}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'trash' && (
|
||||
<motion.div
|
||||
key="trash"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TrashView
|
||||
deletedNotes={notes.filter(n => n.isDeleted)}
|
||||
deletedCarnets={carnets.filter(c => c.isDeleted)}
|
||||
onRestoreNote={handleRestoreNote}
|
||||
onRestoreCarnet={handleRestoreCarnet}
|
||||
onPermanentDeleteNote={handlePermanentDeleteNote}
|
||||
onPermanentDeleteCarnet={handlePermanentDeleteCarnet}
|
||||
onEmptyTrash={() => {
|
||||
setNotes(notes.filter(n => !n.isDeleted));
|
||||
setCarnets(carnets.filter(c => !c.isDeleted));
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'agents' && (
|
||||
<motion.div
|
||||
key="agents"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<AgentsView
|
||||
selectedAgentId={selectedAgentId}
|
||||
setSelectedAgentId={setSelectedAgentId}
|
||||
carnets={carnets}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'settings' && (
|
||||
<motion.div
|
||||
key="settings"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<SettingsView
|
||||
activeSettingsTab={activeSettingsTab}
|
||||
setActiveSettingsTab={setActiveSettingsTab}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'brainstorm' && (
|
||||
<motion.div
|
||||
key="brainstorm"
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<BrainstormView
|
||||
notes={notes}
|
||||
onConvertNote={handleConvertIdeaToNote}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'insights' && (
|
||||
<motion.div
|
||||
key="insights"
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<InsightsView
|
||||
notes={notes}
|
||||
onUpdateNotes={setNotes}
|
||||
onNoteSelect={(noteId) => {
|
||||
setActiveView('notebooks');
|
||||
setActiveNoteId(noteId);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'temporal' && (
|
||||
<motion.div
|
||||
key="temporal"
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TemporalView
|
||||
notes={notes}
|
||||
accessLogs={accessLogs}
|
||||
onNoteSelect={(noteId) => {
|
||||
setActiveView('notebooks');
|
||||
setActiveNoteId(noteId);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AISidebar
|
||||
isOpen={isAISidebarOpen}
|
||||
setIsOpen={setIsAISidebarOpen}
|
||||
activeNote={activeNote}
|
||||
aiTab={aiTab}
|
||||
setAiTab={setAiTab}
|
||||
selectedTone={selectedTone}
|
||||
setSelectedTone={setSelectedTone}
|
||||
carnets={carnets}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
<AnimatePresence>
|
||||
{showNewCarnetModal.isOpen && (
|
||||
<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({ isOpen: 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 dark:bg-dark-paper border border-border shadow-2xl rounded-2xl p-8"
|
||||
>
|
||||
<h3 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink mb-2">
|
||||
{showNewCarnetModal.isRenaming ? 'Rename Carnet' : (showNewCarnetModal.parentId ? 'Create Sub-Carnet' : 'Create New Carnet')}
|
||||
</h3>
|
||||
{showNewCarnetModal.parentId && !showNewCarnetModal.isRenaming && (
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest font-bold mb-6">
|
||||
Inside: {carnets.find(c => c.id === showNewCarnetModal.parentId)?.name}
|
||||
</p>
|
||||
)}
|
||||
<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 dark:bg-[#2A2A2A] border border-border rounded-lg px-4 py-3 outline-none focus:border-ink transition-colors font-serif italic text-lg text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setNewCarnetName('');
|
||||
}}
|
||||
className="flex-1 py-3 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 dark:hover:bg-white/5 transition-colors text-ink dark:text-dark-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-3 bg-ink dark:bg-ochre text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{showNewCarnetModal.isRenaming ? 'Rename' : '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 dark:bg-dark-paper border border-border shadow-2xl rounded-2xl p-10"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{slashMenu?.isOpen && (
|
||||
<SlashMenu
|
||||
position={{ top: slashMenu.top, left: slashMenu.left }}
|
||||
onSelect={(type) => { console.log(type); setSlashMenu(null); }}
|
||||
onClose={() => setSlashMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<h3 className="text-3xl font-serif font-medium text-ink dark:text-dark-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 dark:bg-[#2A2A2A] border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-serif text-2xl text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</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)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
placeholder="Describe the spatial logic, materiality, and light interactions... (Type '/' for commands)"
|
||||
rows={6}
|
||||
className="w-full bg-white dark:bg-[#2A2A2A] border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-light leading-relaxed resize-none text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</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 dark:hover:bg-white/5 transition-colors text-ink dark:text-dark-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-4 bg-ink dark:bg-ochre text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Save Note
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
466
architectural-grid12/src/components/AISidebar.tsx
Normal file
466
architectural-grid12/src/components/AISidebar.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
FileCode,
|
||||
Globe,
|
||||
Send,
|
||||
Scissors,
|
||||
Zap,
|
||||
Languages,
|
||||
Layout,
|
||||
ArrowRightLeft,
|
||||
BookOpen,
|
||||
History,
|
||||
Target,
|
||||
Network,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { AITab, AITone, Note, Carnet } from '../types';
|
||||
import { HierarchicalCarnetSelector } from './HierarchicalCarnetSelector';
|
||||
|
||||
interface AISidebarProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
activeNote: Note | undefined;
|
||||
aiTab: AITab;
|
||||
setAiTab: (tab: AITab) => void;
|
||||
selectedTone: AITone;
|
||||
setSelectedTone: (tone: AITone) => void;
|
||||
carnets: Carnet[];
|
||||
}
|
||||
|
||||
export const AISidebar: React.FC<AISidebarProps> = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
activeNote,
|
||||
aiTab,
|
||||
setAiTab,
|
||||
selectedTone,
|
||||
setSelectedTone,
|
||||
carnets
|
||||
}) => {
|
||||
const [selectedContextId, setSelectedContextId] = React.useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<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"
|
||||
>
|
||||
<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-ochre" />
|
||||
IA Assistant
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(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>
|
||||
|
||||
<div className="flex border-b border-border px-2">
|
||||
{(['discussion', 'actions', 'explore', 'resources'] as AITab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setAiTab(tab)}
|
||||
className={`flex-1 py-3 text-[10px] uppercase tracking-[0.2em] font-bold transition-all relative
|
||||
${aiTab === tab ? 'text-manganese' : '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-ochre"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
<AnimatePresence mode="wait">
|
||||
{aiTab === 'explore' && (
|
||||
<motion.div
|
||||
key="explore"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<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">Intelligence Modules</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
// These will be handled in App.tsx by observing activeView
|
||||
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'brainstorm' }));
|
||||
}}
|
||||
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-ochre/30 transition-all text-left overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Zap size={60} className="text-ochre" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-ochre/10 rounded-xl text-ochre group-hover:bg-ochre group-hover:text-white transition-colors">
|
||||
<Zap size={20} fill="currentColor" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">Brainstorm Wave</h5>
|
||||
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Unfold dimensions of thought</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'insights' }));
|
||||
}}
|
||||
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-indigo-500/30 transition-all text-left overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Network size={60} className="text-indigo-500" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-indigo-500/10 rounded-xl text-indigo-500 group-hover:bg-indigo-500 group-hover:text-white transition-colors">
|
||||
<Network size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">Semantic Network</h5>
|
||||
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Detect clusters and bridges</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'temporal' }));
|
||||
}}
|
||||
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-rose-500/30 transition-all text-left overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Clock size={60} className="text-rose-500" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-rose-500/10 rounded-xl text-rose-500 group-hover:bg-rose-500 group-hover:text-white transition-colors">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">Temporal Forecast</h5>
|
||||
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Predict relevance recurrence</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-50 dark:bg-white/5 border border-dashed border-border mt-6">
|
||||
<p className="text-[10px] text-muted-ink leading-relaxed font-medium italic text-center">
|
||||
Ces modules utilisent les embeddings du modèle Gemini pour analyser graphiquement vos pensées.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{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">Source du Contexte</label>
|
||||
<div className="space-y-3">
|
||||
<div className="w-full p-3 bg-glass border border-border rounded-lg text-xs flex items-center justify-between cursor-default backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode size={14} className="text-blueprint" />
|
||||
<span className="font-medium text-ink">Note Active</span>
|
||||
</div>
|
||||
<div className="text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter italic">Auto</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">+ Carnet</span>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<HierarchicalCarnetSelector
|
||||
carnets={carnets}
|
||||
selectedId={selectedContextId}
|
||||
onSelect={setSelectedContextId}
|
||||
placeholder="Inclure un carnet..."
|
||||
className="w-full"
|
||||
/>
|
||||
</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-manganese text-paper border-manganese shadow-lg shadow-manganese/10' : 'bg-glass border-border text-muted-ink hover:border-ink/20'}`}
|
||||
>
|
||||
{tone.toUpperCase().substring(0, 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">
|
||||
<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', color: 'ochre' },
|
||||
{ icon: <Scissors size={14} />, label: 'Raccourcir', color: 'rust' },
|
||||
{ icon: <Zap size={14} />, label: 'Améliorer', color: 'sage' },
|
||||
{ icon: <Languages size={14} />, label: 'Traduire', color: 'slate' },
|
||||
].map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="flex flex-col items-center gap-3 p-4 bg-glass border border-border rounded-xl transition-all group hover:border-ink/20"
|
||||
>
|
||||
<div className={`p-2 rounded-lg bg-slate-50 dark:bg-white/10 transition-colors group-hover:bg-manganese group-hover:text-paper shadow-sm text-ink/60`}>
|
||||
{action.icon}
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-ink/80 uppercase tracking-widest">{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="col-span-2 flex items-center justify-center gap-3 py-3 px-4 bg-glass border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-slate-50 dark:hover:bg-white/10 transition-colors hover:border-ink/20 uppercase tracking-widest">
|
||||
<FileCode size={14} className="text-muted-ink" />
|
||||
Convertir en Markdown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-blueprint/30 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-blueprint" />
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-blueprint">
|
||||
<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-[0.2em] font-bold text-muted-ink/60 px-1">Thème</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-blueprint/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-[0.2em] font-bold text-muted-ink/60 px-1">Style</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-blueprint/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-blueprint text-paper rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-blueprint/20 uppercase tracking-[0.2em]">
|
||||
Générer
|
||||
<ArrowRightLeft size={14} className="opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-sage/30 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-sage" />
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-sage">
|
||||
<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-[0.2em] font-bold text-muted-ink/60 px-1">Type</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-sage/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-[0.2em] font-bold text-muted-ink/60 px-1">Style</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-sage/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-sage text-paper rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-sage/20 uppercase tracking-[0.2em]">
|
||||
Tracer
|
||||
<ArrowRightLeft size={14} className="opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-glass border border-border rounded-lg pl-3 pr-10 py-3 text-xs outline-none focus:border-blueprint 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-glass border border-border rounded-lg p-4 text-xs outline-none focus:border-blueprint 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-sage/10 border-sage/50 ring-1 ring-sage/10' : 'bg-white border-border hover:bg-slate-50'}`}>
|
||||
<span className={`text-[11px] font-bold ${mode.id === 'append' ? 'text-sage' : '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-blueprint text-white rounded-xl text-sm font-bold flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-blueprint/20">
|
||||
<Sparkles size={18} />
|
||||
Générer l'aperçu
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<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-glass backdrop-blur-sm border border-border rounded-2xl p-4 pr-12 text-sm outline-none focus:border-blueprint 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-blueprint text-white rounded-lg transition-transform hover:scale-105 active:scale-95 shadow-lg shadow-blueprint/10">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
325
architectural-grid12/src/components/AgentsView.tsx
Normal file
325
architectural-grid12/src/components/AgentsView.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
Activity,
|
||||
Trash2,
|
||||
Edit3,
|
||||
Play,
|
||||
Eye,
|
||||
Microscope,
|
||||
Globe,
|
||||
Layers,
|
||||
Zap,
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Info,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Carnet, Note } from '../types';
|
||||
import { HierarchicalCarnetSelector } from './HierarchicalCarnetSelector';
|
||||
|
||||
interface AgentsViewProps {
|
||||
selectedAgentId: string | null;
|
||||
setSelectedAgentId: (id: string | null) => void;
|
||||
carnets: Carnet[];
|
||||
}
|
||||
|
||||
export const AgentsView: React.FC<AgentsViewProps> = ({
|
||||
selectedAgentId,
|
||||
setSelectedAgentId,
|
||||
carnets
|
||||
}) => {
|
||||
const [selectedCarnetForAgent, setSelectedCarnetForAgent] = React.useState<string | null>('4');
|
||||
const [agentType, setAgentType] = React.useState<'Surveillant' | 'Personnalisé' | 'Slides' | 'Diagramme'>('Diagramme');
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-y-auto custom-scrollbar bg-[#F9F8F6] dark:bg-dark-paper space-y-12">
|
||||
{!selectedAgentId ? (
|
||||
<>
|
||||
<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-end">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink">Mes Agents</h1>
|
||||
<p className="text-sm text-muted-ink font-light">Automatisez vos tâches de veille et de recherche.</p>
|
||||
</div>
|
||||
<button className="px-6 py-2.5 bg-ink text-paper text-sm font-medium rounded-xl hover:opacity-90 transition-all flex items-center gap-3 shadow-lg shadow-ink/10">
|
||||
<Plus size={18} />
|
||||
Nouvel Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 border-b border-ink/5 pt-4">
|
||||
{['Tous', 'Veilleur', 'Chercheur', 'Surveillant', 'Personnalisé'].map((tag, i) => (
|
||||
<button key={i} className={`pb-4 text-xs font-bold uppercase tracking-widest transition-all relative ${i === 0 ? 'text-ink' : 'text-muted-ink hover:text-ink/60'}`}>
|
||||
{tag}
|
||||
{i === 0 && <motion.div layoutId="activeAgentTag" className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-12 flex-1 pb-20 space-y-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ id: 'a1', icon: <Eye size={20} className="text-amber-600" />, title: 'Surveillant de Notes', status: 'Réussi', type: 'SURVEILLANT', meta: 'Hebdomadaire • 6 exéc.', desc: 'Analyse les notes récentes d’un carnet et suggère des compléments, références et liens.' },
|
||||
{ id: 'a2', icon: <Microscope size={20} className="text-indigo-600" />, title: 'Chercheur de Sujet', status: 'Réussi', type: 'CHERCHEUR', meta: 'Hebdomadaire • 14 exéc.', desc: 'Recherche des informations approfondies sur les derniers modèles de Deepseek et voir l’avis des utilisateurs.' },
|
||||
{ id: 'a3', icon: <Globe size={20} className="text-emerald-600" />, title: 'Veille IA', status: 'Réussi', type: 'VEILLEUR', meta: 'Quotidien • 20 exéc.', desc: 'Scrape les flux RSS de 6 sites IA (The Verge, TechCrunch...) et génère un résumé.' },
|
||||
].map((agent, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
className="bg-white dark:bg-white/5 border border-border rounded-2xl p-6 space-y-6 hover:border-ink/20 transition-all group cursor-pointer shadow-sm relative overflow-hidden"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-slate-50 dark:bg-white/10 rounded-xl group-hover:bg-ink group-hover:text-paper transition-all">
|
||||
{agent.icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-ink">{agent.title}</h4>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-ink opacity-60">{agent.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-8 h-4 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-emerald-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-ink leading-relaxed line-clamp-3">
|
||||
{agent.desc}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-ink font-medium">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1"><Clock size={10} /> {agent.meta.split('•')[0]}</span>
|
||||
<span>{agent.meta.split('•')[1]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-ink font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="uppercase tracking-tight">Prochaine exécution</span>
|
||||
<span className="text-ink">Hebdomadaire</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="uppercase tracking-tight">Dernier statut</span>
|
||||
<span className="text-emerald-600 flex items-center gap-1"><Activity size={8} /> {agent.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-t border-border pt-4">
|
||||
<button className="py-2 border border-border rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 flex items-center justify-center transition-colors text-muted-ink hover:text-ink"><Edit3 size={14} /> <span className="ml-2 text-[10px] font-bold uppercase">Modifier</span></button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
className="py-2 border border-border rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 flex items-center justify-center transition-colors text-muted-ink hover:text-ink"
|
||||
>
|
||||
<Play size={14} className="fill-current" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
className="py-2 border border-border rounded-lg hover:bg-rose-50 hover:text-rose-600 hover:border-rose-100 flex items-center justify-center transition-colors text-muted-ink"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<h5 className="text-[10px] font-bold uppercase tracking-[0.3em] text-muted-ink whitespace-nowrap">Modèles</h5>
|
||||
<div className="h-px w-full bg-border/40" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ title: 'Veille IA', desc: 'Scrape les flux RSS de 6 sites IA et génère un résumé hebdomadaire.', icon: <Globe size={18} /> },
|
||||
{ title: 'Veille Tech', desc: 'Crée un résumé quotidien des news Hacker News et Product Hunt.', icon: <Zap size={18} /> },
|
||||
{ title: 'Veille Dev', desc: 'Surveille les repos GitHub pour détecter les nouvelles releases.', icon: <Layers size={18} /> },
|
||||
].map((model, i) => (
|
||||
<div key={i} className="bg-white/40 dark:bg-white/5 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-white dark:hover:bg-white/10 hover:border-ink/20 transition-all">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-50 dark:bg-white/10 flex items-center justify-center text-muted-ink group-hover:bg-ink group-hover:text-paper mb-4 transition-all">
|
||||
{model.icon}
|
||||
</div>
|
||||
<h4 className="text-[13px] font-bold text-ink mb-2">{model.title}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-4">{model.desc}</p>
|
||||
<button className="text-[11px] font-bold uppercase tracking-widest text-ink hover:opacity-60 transition-opacity flex items-center gap-2">
|
||||
<Plus size={14} /> Installer
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<header className="px-12 py-10 border-b border-border bg-white dark:bg-paper backdrop-blur-md sticky top-0 z-30">
|
||||
<div className="flex items-center justify-between max-w-5xl mx-auto">
|
||||
<button
|
||||
onClick={() => setSelectedAgentId(null)}
|
||||
className="flex items-center gap-3 text-xs font-bold uppercase tracking-widest text-muted-ink hover:text-ink transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Retour
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="px-5 py-2 text-xs font-bold uppercase tracking-widest border border-border rounded-xl hover:bg-slate-50 dark:hover:bg-white/5 transition-all">
|
||||
Logs
|
||||
</button>
|
||||
<button className="px-6 py-2 bg-ink text-paper text-xs font-bold uppercase tracking-widest rounded-xl hover:opacity-90 transition-all shadow-lg shadow-ink/10">
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 px-12 py-16 max-w-5xl mx-auto w-full space-y-24">
|
||||
<section className="space-y-12">
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete">Sélectionnez le type d'agent</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-4xl mx-auto">
|
||||
{[
|
||||
{ id: 'Surveillant', icon: <Eye size={18} />, label: 'Surveillant', desc: 'Surveille un carnet et analyse les notes' },
|
||||
{ id: 'Personnalisé', icon: <Layers size={18} />, label: 'Personnalisé', desc: 'Agent libre avec votre propre prompt' },
|
||||
{ id: 'Slides', icon: <Layers size={18} />, label: 'Slides', desc: 'Crée une présentation PowerPoint à partir de notes' },
|
||||
{ id: 'Diagramme', icon: <Zap size={18} />, label: 'Diagramme', desc: 'Crée un diagramme Excalidraw à partir de notes' },
|
||||
].map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setAgentType(type.id as any)}
|
||||
className={`p-6 rounded-2xl border-2 transition-all flex flex-col items-center gap-3 text-center group relative
|
||||
${agentType === type.id ? 'border-blueprint bg-white shadow-xl shadow-blueprint/10' : 'border-border bg-white/50 hover:bg-white'}`}
|
||||
>
|
||||
<div className={`p-3 rounded-xl transition-all ${agentType === type.id ? 'bg-blueprint text-white' : 'bg-slate-50 text-concrete group-hover:text-ink'}`}>
|
||||
{type.icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[13px] font-bold text-ink">{type.label}</p>
|
||||
<p className="text-[10px] text-muted-ink leading-tight">{type.desc}</p>
|
||||
</div>
|
||||
<div className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all
|
||||
${agentType === type.id ? 'border-blueprint' : 'border-border opacity-20'}`}>
|
||||
{agentType === type.id && <div className="w-2 h-2 bg-blueprint rounded-full" />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
|
||||
CONFIGURATION <Info size={12} className="opacity-40" />
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-6 py-2 border-2 border-rose-100 bg-rose-50 rounded-xl text-rose-500 text-[11px] font-bold uppercase tracking-widest hover:bg-rose-100 transition-colors">
|
||||
<Trash2 size={14} /> Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-white/5 border border-border/60 rounded-[32px] p-12 space-y-12 shadow-sm">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">DESCRIPTION (OPTIONEL)</label>
|
||||
<Info size={12} className="text-concrete/40" />
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-black/20 border border-border/40 rounded-2xl p-6 text-sm outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all font-light leading-relaxed resize-none text-ink"
|
||||
placeholder="Décrivez brièvement le rôle de cet agent..."
|
||||
defaultValue="Lit une note et génère un diagramme visuel dans le Lab Excalidraw."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">CARNET À SURVEILLER</label>
|
||||
<Info size={12} className="text-concrete/40" />
|
||||
</div>
|
||||
<HierarchicalCarnetSelector
|
||||
carnets={carnets}
|
||||
selectedId={selectedCarnetForAgent}
|
||||
onSelect={setSelectedCarnetForAgent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">NOTES À ANALYSER</label>
|
||||
<Info size={12} className="text-concrete/40" />
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-black/20 border border-border/40 rounded-2xl overflow-hidden divide-y divide-border/20">
|
||||
{[
|
||||
'Résumé du conteneur LXC devSandbox',
|
||||
'Connexion SSH sans mot de passe à devSandbox',
|
||||
'Gateway token (blank to generate)',
|
||||
'Procédure d\'accès à openclaw',
|
||||
'Derniers commits du repo Momento'
|
||||
].map((note, i) => (
|
||||
<label key={i} className="flex items-center gap-4 px-6 py-4 cursor-pointer hover:bg-white/50 transition-colors group">
|
||||
<div className={`w-5 h-5 rounded border transition-all flex items-center justify-center
|
||||
${i === 0 ? 'bg-blueprint border-blueprint text-white' : 'bg-white border-border group-hover:border-blueprint/40'}`}>
|
||||
{i === 0 && <Check size={12} />}
|
||||
</div>
|
||||
<input type="checkbox" className="hidden" defaultChecked={i === 0} />
|
||||
<span className={`text-[13px] transition-colors ${i === 0 ? 'font-medium text-ink' : 'text-muted-ink'}`}>{note}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-concrete/60 italic font-medium">{1} note(s) sélectionnée(s)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">TYPE DE DIAGRAMME</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
|
||||
{[
|
||||
'Auto (détection métier)', 'Flowchart (processus)',
|
||||
'Mindmap (idées)', 'Organigramme (équipes)',
|
||||
'Timeline / roadmap', 'Process map (opérations)',
|
||||
'Architecture cloud (zones/RG)'
|
||||
].map((type, i) => (
|
||||
<button key={i} className={`px-6 py-4 rounded-xl border text-[13px] text-left transition-all
|
||||
${i === 0 ? 'border-ink bg-slate-50 font-bold text-ink ring-2 ring-ink/5' : 'border-border text-concrete hover:border-concrete/40 hover:bg-slate-50/50'}`}>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">STYLE DU DIAGRAMME EXCALIDRAW</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{[
|
||||
'Coloré (Excalidraw)', 'Sketch+ (Excalidraw accentué)', 'Austère (sobre)'
|
||||
].map((style, i) => (
|
||||
<button key={i} className={`px-6 py-4 rounded-xl border text-[13px] transition-all
|
||||
${i === 1 ? 'border-ink bg-white font-bold text-ink ring-2 ring-ink/5 shadow-lg' : 'border-border text-concrete hover:bg-slate-50'}`}>
|
||||
{style}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,749 @@
|
||||
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Zap,
|
||||
Search,
|
||||
ArrowRight,
|
||||
History,
|
||||
Plus,
|
||||
Wind,
|
||||
PlusCircle,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Share2,
|
||||
Users,
|
||||
Check,
|
||||
Download,
|
||||
Activity,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { WaveCanvas } from './WaveCanvas';
|
||||
import { BrainstormSession, BrainstormIdea, Note } from '../../types';
|
||||
import { generateBrainstormWave, generateExpansion, getEmbedding, cosineSimilarity } from '../../services/geminiService';
|
||||
|
||||
interface BrainstormViewProps {
|
||||
notes: Note[];
|
||||
onConvertNote: (idea: BrainstormIdea) => void;
|
||||
}
|
||||
|
||||
export const BrainstormView: React.FC<BrainstormViewProps> = ({ notes, onConvertNote }) => {
|
||||
const [seedInput, setSeedInput] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessions, setSessions] = useState<BrainstormSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [ideas, setIdeas] = useState<BrainstormIdea[]>([]);
|
||||
const [selectedIdeaId, setSelectedIdeaId] = useState<string | null>(null);
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
|
||||
const [manualTitle, setManualTitle] = useState('');
|
||||
const [shareStatus, setShareStatus] = useState<'idle' | 'copying' | 'copied'>('idle');
|
||||
const [showActivity, setShowActivity] = useState(false);
|
||||
const [activities, setActivities] = useState<{ id: string; type: string; message: string; timestamp: string }[]>([]);
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const addActivity = (message: string, type: string = 'info', broadcast: boolean = true) => {
|
||||
const newActivity = {
|
||||
id: uuidv4(),
|
||||
type,
|
||||
message,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
};
|
||||
setActivities(prev => [newActivity, ...prev].slice(0, 50));
|
||||
if (broadcast && socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: 'activity', activity: newActivity }));
|
||||
}
|
||||
};
|
||||
|
||||
// WebSocket Connection
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('WS Shared Brainstorm connected');
|
||||
if (activeSessionId) {
|
||||
socket.send(JSON.stringify({ type: 'join', sessionId: activeSessionId }));
|
||||
}
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'idea_added') {
|
||||
const newIdea = data.idea;
|
||||
setIdeas(prev => {
|
||||
if (prev.find(i => i.id === newIdea.id)) return prev;
|
||||
return [...prev, newIdea];
|
||||
});
|
||||
}
|
||||
if (data.type === 'idea_updated') {
|
||||
const updatedIdea = data.idea;
|
||||
setIdeas(prev => prev.map(i => i.id === updatedIdea.id ? updatedIdea : i));
|
||||
}
|
||||
if (data.type === 'activity') {
|
||||
setActivities(prev => [data.activity, ...prev].slice(0, 50));
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync session joining
|
||||
useEffect(() => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN && activeSessionId) {
|
||||
socketRef.current.send(JSON.stringify({ type: 'join', sessionId: activeSessionId }));
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/brainstorm/sessions')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setSessions(data);
|
||||
// Check for initial session from URL parameter (passed via window by App.tsx)
|
||||
const initialId = (window as any).initialSessionId;
|
||||
if (initialId && data.find((s: any) => s.id === initialId)) {
|
||||
setActiveSessionId(initialId);
|
||||
delete (window as any).initialSessionId;
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Failed to load sessions", err));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSessionId) {
|
||||
fetch(`/api/brainstorm/${activeSessionId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.ideas) {
|
||||
setIdeas(prev => {
|
||||
const filtered = prev.filter(i => i.sessionId !== activeSessionId);
|
||||
return [...filtered, ...data.ideas];
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Failed to load ideas", err));
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
const activeSession = useMemo(() =>
|
||||
sessions.find(s => s.id === activeSessionId),
|
||||
[activeSessionId, sessions]);
|
||||
|
||||
const activeIdeas = useMemo(() =>
|
||||
ideas.filter(i => i.sessionId === activeSessionId),
|
||||
[activeSessionId, ideas]);
|
||||
|
||||
const selectedIdea = useMemo(() =>
|
||||
ideas.find(i => i.id === selectedIdeaId),
|
||||
[selectedIdeaId, ideas]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRemoteStart = (e: any) => {
|
||||
if (e.detail?.seed) {
|
||||
handleStartBrainstorm(e.detail.seed, e.detail.sourceNoteId);
|
||||
}
|
||||
};
|
||||
window.addEventListener('start-brainstorm', handleRemoteStart);
|
||||
return () => window.removeEventListener('start-brainstorm', handleRemoteStart);
|
||||
}, [notes]);
|
||||
|
||||
const handleStartBrainstorm = async (seed: string, sourceNoteId?: string) => {
|
||||
if (!seed.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1. Create session on backend
|
||||
const sessionRes = await fetch('/api/brainstorm/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
seedIdea: seed,
|
||||
sourceNoteId
|
||||
})
|
||||
});
|
||||
const session = await sessionRes.json();
|
||||
if (!sessionRes.ok) throw new Error(session.error || "Failed to create session");
|
||||
|
||||
setSessions(prev => [session, ...prev]);
|
||||
setActiveSessionId(session.id);
|
||||
setSeedInput('');
|
||||
|
||||
// 2. Generate waves in frontend concurrently
|
||||
const contextSummaries = notes.slice(0, 5).map(n => n.title).join(', ');
|
||||
|
||||
const wavePromises = [1, 2, 3].map(async (num) => {
|
||||
try {
|
||||
const generated = await generateBrainstormWave(seed, num, contextSummaries);
|
||||
return generated.map(g => ({
|
||||
...g,
|
||||
waveNumber: num
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(`Wave ${num} failed`, e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const wavesResults = await Promise.all(wavePromises);
|
||||
const allNewIdeas = wavesResults.flat();
|
||||
|
||||
if (allNewIdeas.length === 0) {
|
||||
throw new Error("No ideas were generated. Gemini might be shy today.");
|
||||
}
|
||||
|
||||
// 3. Save ideas to backend
|
||||
const ideasRes = await fetch(`/api/brainstorm/${session.id}/ideas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ideas: allNewIdeas })
|
||||
});
|
||||
const savedIdeas = await ideasRes.json();
|
||||
setIdeas(prev => [...prev, ...savedIdeas]);
|
||||
|
||||
addActivity(`Generated ${savedIdeas.length} ideas for Wave ${allNewIdeas[0]?.waveNumber || ''}`);
|
||||
|
||||
// Notify others
|
||||
savedIdeas.forEach((idea: any) => {
|
||||
socketRef.current?.send(JSON.stringify({ type: 'idea_added', idea }));
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Brainstorm failed:", err);
|
||||
setError(err.message || "An unexpected error occurred while brainstorming.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateIdea = async (ideaId: string, updates: Partial<BrainstormIdea>) => {
|
||||
try {
|
||||
const res = await fetch(`/api/brainstorm/ideas/${ideaId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
const updated = await res.json();
|
||||
setIdeas(prev => prev.map(i => i.id === ideaId ? updated : i));
|
||||
|
||||
// Notify others
|
||||
socketRef.current?.send(JSON.stringify({ type: 'idea_updated', idea: updated }));
|
||||
} catch (err) {
|
||||
console.error("Update failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeepenIdea = async (idea: BrainstormIdea) => {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const generated = await generateExpansion(idea.title, idea.description);
|
||||
const newIdeasData = generated.map(g => ({
|
||||
...g,
|
||||
waveNumber: Math.min(idea.waveNumber + 1, 3),
|
||||
parentIdeaId: idea.id
|
||||
}));
|
||||
|
||||
const res = await fetch(`/api/brainstorm/${idea.sessionId}/ideas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ideas: newIdeasData })
|
||||
});
|
||||
const savedIdeas = await res.json();
|
||||
setIdeas(prev => [...prev, ...savedIdeas]);
|
||||
addActivity(`Expanded idea: ${idea.title}`);
|
||||
|
||||
// Notify others
|
||||
savedIdeas.forEach((i: any) => {
|
||||
socketRef.current?.send(JSON.stringify({ type: 'idea_added', idea: i }));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Deepen failed", err);
|
||||
setError("Failed to expand this idea.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissIdea = (ideaId: string) => {
|
||||
updateIdea(ideaId, { status: 'dismissed' });
|
||||
setSelectedIdeaId(null);
|
||||
};
|
||||
|
||||
const handleConvertToNote = (idea: BrainstormIdea) => {
|
||||
updateIdea(idea.id, { status: 'converted' });
|
||||
onConvertNote(idea);
|
||||
};
|
||||
|
||||
const handleManualAdd = async (title: string, parentId?: string) => {
|
||||
if (!title.trim() || !activeSessionId) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
let finalParentId = parentId;
|
||||
let waveNumber = 1;
|
||||
|
||||
if (parentId) {
|
||||
const p = ideas.find(i => i.id === parentId);
|
||||
if (p) waveNumber = Math.min(p.waveNumber + 1, 3);
|
||||
} else if (activeIdeas.length > 0) {
|
||||
// Semantic auto-placement if no parent is specified
|
||||
try {
|
||||
const newEmbedding = await getEmbedding(title);
|
||||
let bestSim = -1;
|
||||
let bestParent: BrainstormIdea | null = null;
|
||||
|
||||
for (const idea of activeIdeas) {
|
||||
const ideaEmbedding = await getEmbedding(idea.title + " " + idea.description);
|
||||
const sim = cosineSimilarity(newEmbedding, ideaEmbedding);
|
||||
if (sim > bestSim) {
|
||||
bestSim = sim;
|
||||
bestParent = idea;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestParent && bestSim > 0.7) {
|
||||
finalParentId = bestParent.id;
|
||||
waveNumber = Math.min(bestParent.waveNumber + 1, 3);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Semantic placement failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
const newIdeaData = [{
|
||||
title: title,
|
||||
description: "",
|
||||
waveNumber: waveNumber,
|
||||
connectionToSeed: finalParentId
|
||||
? `Manual addition (auto-linked)`
|
||||
: "Manual addition to root",
|
||||
noveltyScore: 5,
|
||||
parentIdeaId: finalParentId
|
||||
}];
|
||||
|
||||
const res = await fetch(`/api/brainstorm/${activeSessionId}/ideas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ideas: newIdeaData })
|
||||
});
|
||||
const saved = await res.json();
|
||||
setIdeas(prev => [...prev, ...saved]);
|
||||
|
||||
addActivity(`Manually added idea: ${title}`);
|
||||
|
||||
// Notify
|
||||
saved.forEach((i: any) => socketRef.current?.send(JSON.stringify({ type: 'idea_added', idea: i })));
|
||||
|
||||
setEditingNodeId(null);
|
||||
setManualTitle('');
|
||||
} catch (err) {
|
||||
console.error("Manual add failed", err);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvite = () => {
|
||||
if (!activeSessionId) return;
|
||||
const shareUrl = `${window.location.origin}${window.location.pathname}?session=${activeSessionId}`;
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
setShareStatus('copied');
|
||||
addActivity(`Invitation link copied to clipboard`);
|
||||
setTimeout(() => setShareStatus('idle'), 2000);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!activeSession) return;
|
||||
|
||||
let markdown = `# Brainstorm : ${activeSession.seedIdea}\n\n`;
|
||||
markdown += `Date : ${new Date(activeSession.createdAt).toLocaleDateString()}\n\n`;
|
||||
|
||||
[1, 2, 3].forEach(waveNum => {
|
||||
const waveIdeas = activeIdeas.filter(i => i.waveNumber === waveNum);
|
||||
if (waveIdeas.length > 0) {
|
||||
markdown += `## Vague ${waveNum}\n\n`;
|
||||
waveIdeas.forEach(idea => {
|
||||
markdown += `### ${idea.title}\n`;
|
||||
markdown += `${idea.description}\n`;
|
||||
markdown += `*Score de nouveauté : ${idea.noveltyScore}/10*\n`;
|
||||
markdown += `*Connexion : ${idea.connectionToSeed}*\n\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onConvertNote({
|
||||
id: uuidv4(),
|
||||
title: `Brainstorm Export: ${activeSession.seedIdea}`,
|
||||
description: markdown,
|
||||
sessionId: activeSession.id,
|
||||
waveNumber: 0,
|
||||
connectionToSeed: "Export",
|
||||
noveltyScore: 10,
|
||||
status: 'converted'
|
||||
});
|
||||
|
||||
addActivity(`Session exported to notes`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#F8F7F2] dark:bg-[#0A0A0A] overflow-hidden">
|
||||
{/* Header / Start area */}
|
||||
<div className="p-12 border-b border-border/20 backdrop-blur-md bg-white/20 dark:bg-dark-paper/20 z-10 relative overflow-hidden">
|
||||
{/* Architectural Grid Background */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05]"
|
||||
style={{ backgroundImage: 'linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
||||
|
||||
<div className="max-w-4xl mx-auto relative">
|
||||
<div className="flex items-center gap-5 mb-8">
|
||||
<motion.div
|
||||
animate={{ rotate: isGenerating ? 360 : 0 }}
|
||||
transition={{ repeat: isGenerating ? Infinity : 0, duration: 20, ease: "linear" }}
|
||||
className="w-14 h-14 rounded-2xl bg-ochre shadow-[0_0_20px_rgba(212,163,115,0.2)] flex items-center justify-center text-paper"
|
||||
>
|
||||
<Wind size={28} />
|
||||
</motion.div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-4xl font-serif font-medium text-ink dark:text-dark-ink tracking-tight">Waves of Thought</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="w-8 h-px bg-ochre/40" />
|
||||
<p className="text-[10px] text-concrete tracking-[0.3em] uppercase font-bold">Unfold dimensions of potentiality</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeSession && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 border border-border rounded-xl text-xs font-bold uppercase tracking-widest text-concrete hover:text-ochre transition-all shadow-sm"
|
||||
title="Export to Note"
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowActivity(!showActivity)}
|
||||
className={`flex items-center gap-2 px-4 py-2 border border-border rounded-xl text-xs font-bold uppercase tracking-widest transition-all shadow-sm ${showActivity ? 'bg-ink text-paper' : 'bg-white dark:bg-white/5 text-concrete hover:text-ink'}`}
|
||||
title="Show Activity"
|
||||
>
|
||||
<Activity size={14} />
|
||||
<span className="hidden sm:inline">Activity</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInvite}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 border border-border rounded-xl text-xs font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all shadow-sm"
|
||||
>
|
||||
{shareStatus === 'copied' ? <Check size={14} className="text-emerald-500" /> : <Share2 size={14} />}
|
||||
{shareStatus === 'copied' ? 'Link Copied' : 'Invite'}
|
||||
</button>
|
||||
<div className="flex items-center gap-1 px-3 py-2 bg-emerald-500/10 rounded-full">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<Users size={14} className="text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-ochre/20 to-blueprint/20 rounded-[28px] blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-700" />
|
||||
<input
|
||||
type="text"
|
||||
value={seedInput}
|
||||
onChange={(e) => setSeedInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStartBrainstorm(seedInput)}
|
||||
placeholder="Enter a concept to unfold..."
|
||||
className={`w-full relative bg-white dark:bg-[#1A1A1A] border-2 rounded-2xl px-8 py-7 pr-20 outline-none transition-all text-2xl font-serif italic text-ink dark:text-dark-ink shadow-sm group-hover:shadow-md
|
||||
${error ? 'border-rose-400 focus:ring-rose-100 shadow-rose-100' : 'border-border/40 focus:border-ochre/40 focus:ring-4 focus:ring-ochre/5'}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleStartBrainstorm(seedInput)}
|
||||
disabled={isGenerating || !seedInput.trim()}
|
||||
className="absolute right-4 top-4 bottom-4 px-6 bg-ink dark:bg-ochre text-paper rounded-xl disabled:opacity-50 transition-all hover:scale-[1.02] active:scale-[0.98] flex items-center justify-center gap-2 min-w-[70px] shadow-lg"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="w-6 h-6 border-3 border-paper/30 border-t-paper rounded-full animate-spin" />
|
||||
) : (
|
||||
<Plus size={24} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mt-6 p-5 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 rounded-2xl flex items-start gap-4 text-rose-600 dark:text-rose-400 text-sm overflow-hidden shadow-sm"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full bg-rose-100 dark:bg-rose-500/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<div className="w-2 h-2 rounded-full bg-rose-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold uppercase tracking-wider text-[10px] mb-1">Obstruction detected</p>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{isGenerating && !error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-6 flex items-center gap-4 text-ochre/80 italic font-serif"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{[0.2, 0.4, 0.6].map((d, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, delay: d }}
|
||||
className="w-1.5 h-1.5 rounded-full bg-ochre"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-base tracking-tight">Gemini is harvesting seeds of thought from the digital ether...</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{/* Main Canvas Area */}
|
||||
<div className="flex-1 relative bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#ffffff10_1px,transparent_1px)] [background-size:20px_20px]">
|
||||
{activeSession ? (
|
||||
<div onClick={() => setSelectedIdeaId(null)} className="w-full h-full">
|
||||
<WaveCanvas
|
||||
session={activeSession}
|
||||
ideas={activeIdeas}
|
||||
onNodeSelect={(id) => {
|
||||
setSelectedIdeaId(id);
|
||||
}}
|
||||
onPositionUpdate={(id, pos) => updateIdea(id, { position: pos })}
|
||||
onAddChild={(id) => {
|
||||
setSelectedIdeaId(id);
|
||||
setEditingNodeId(id);
|
||||
}}
|
||||
onManualSubmit={handleManualAdd}
|
||||
onManualCancel={() => setEditingNodeId(null)}
|
||||
editingNodeId={editingNodeId}
|
||||
selectedNodeId={selectedIdeaId}
|
||||
relatedNotes={notes}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20 flex-col gap-6">
|
||||
<Wind size={120} strokeWidth={1} className="text-concrete animate-pulse" />
|
||||
<p className="text-xl font-serif italic text-concrete">The canvas is waiting for your spark...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating UI overlays */}
|
||||
<AnimatePresence>
|
||||
{activeSession && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute bottom-6 left-6 flex gap-2"
|
||||
>
|
||||
<div className="px-4 py-2 bg-paper/80 dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-400 shadow-[0_0_8px_rgba(251,146,60,0.6)]" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 1</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400 shadow-[0_0_8px_rgba(96,165,250,0.6)]" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 2</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-violet-400 shadow-[0_0_8px_rgba(167,139,250,0.6)]" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 3</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setEditingNodeId('new')}
|
||||
className="px-6 py-3 bg-paper dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-concrete hover:bg-ink hover:text-paper transition-all"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Manual Idea
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Activity Sidebar */}
|
||||
<AnimatePresence>
|
||||
{showActivity && (
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed right-0 top-0 h-full w-80 bg-paper dark:bg-dark-paper border-l border-border shadow-2xl z-[70] flex flex-col"
|
||||
>
|
||||
<div className="p-6 border-b border-border flex items-center justify-between bg-ink text-paper">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity size={18} />
|
||||
<h3 className="font-bold uppercase tracking-widest text-xs">Flux d'activité</h3>
|
||||
</div>
|
||||
<button onClick={() => setShowActivity(false)} className="p-1 hover:bg-white/10 rounded-lg">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{activities.length === 0 ? (
|
||||
<p className="text-xs text-concrete text-center italic mt-10">Aucune activité pour le moment</p>
|
||||
) : (
|
||||
activities.map((act) => (
|
||||
<motion.div
|
||||
key={act.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="p-3 bg-white dark:bg-white/5 rounded-xl border border-border/50 relative overflow-hidden group"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-ochre/40" />
|
||||
<p className="text-[11px] font-medium text-ink dark:text-dark-ink">{act.message}</p>
|
||||
<span className="text-[9px] text-concrete font-bold mt-1 block">{act.timestamp}</span>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Right Sidebar Detail Panel */}
|
||||
<AnimatePresence>
|
||||
{selectedIdea && (
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
className="w-[400px] border-l border-border bg-paper dark:bg-dark-paper flex flex-col z-20 shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
|
||||
>
|
||||
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border
|
||||
${selectedIdea.waveNumber === 1 ? 'border-orange-200 bg-orange-50 text-orange-600' :
|
||||
selectedIdea.waveNumber === 2 ? 'border-blue-200 bg-blue-50 text-blue-600' :
|
||||
'border-violet-200 bg-violet-50 text-violet-600'}`}>
|
||||
Vague {selectedIdea.waveNumber}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIdea.status === 'converted' && (
|
||||
<span className="text-[10px] font-bold text-emerald-500 uppercase tracking-widest bg-emerald-500/10 px-2 py-1 rounded-full">Note Created</span>
|
||||
)}
|
||||
<button onClick={() => setSelectedIdeaId(null)} className="p-2 hover:bg-ink/5 rounded-full transition-colors">
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-serif font-medium text-ink dark:text-dark-ink mb-2">{selectedIdea.title}</h2>
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap size={14} className="text-ochre" />
|
||||
<span className="text-xs font-bold text-concrete">Novelty: {selectedIdea.noveltyScore}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-ink/80 dark:text-dark-ink/80 leading-relaxed font-light mb-10 text-lg">
|
||||
{selectedIdea.description}
|
||||
</p>
|
||||
|
||||
<div className="p-6 bg-slate-50 dark:bg-white/5 rounded-2xl border border-border/40 mb-10">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-widest text-concrete mb-3">Origin connection</h4>
|
||||
<p className="text-sm italic text-muted-ink leading-relaxed">
|
||||
"{selectedIdea.connectionToSeed}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedIdea.relatedNoteIds && selectedIdea.relatedNoteIds.length > 0 && (
|
||||
<div className="space-y-4 mb-10">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-widest text-concrete px-1">Semantic Context</h4>
|
||||
{selectedIdea.relatedNoteIds.map(noteId => {
|
||||
const note = notes.find(n => n.id === noteId);
|
||||
return note ? (
|
||||
<div key={noteId} className="p-4 rounded-xl border border-border bg-white dark:bg-white/5 hover:border-ink/20 transition-all cursor-pointer group">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-ink dark:text-dark-ink truncate">{note.title}</h5>
|
||||
<ArrowRight size={14} className="text-concrete group-hover:text-ink transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => handleDeepenIdea(selectedIdea)}
|
||||
disabled={isGenerating}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-ochre/40 hover:bg-ochre/5 transition-all group disabled:opacity-50"
|
||||
>
|
||||
<Wind size={24} className="text-concrete group-hover:text-ochre mb-2" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink text-center">AI Expand</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingNodeId(selectedIdea.id)}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-ink/40 hover:bg-ink/5 transition-all group disabled:opacity-50"
|
||||
>
|
||||
<PlusCircle size={24} className="text-concrete group-hover:text-ink mb-2" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink text-center">Add Child</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleConvertToNote(selectedIdea)}
|
||||
disabled={selectedIdea.status === 'converted'}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-blueprint/40 hover:bg-blueprint/5 transition-all group disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
<FileText size={24} className="text-concrete group-hover:text-blueprint mb-2" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink text-center">Extract Note</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleDismissIdea(selectedIdea.id)}
|
||||
className="w-full py-4 text-[10px] font-bold uppercase tracking-[0.2em] text-concrete hover:text-rose-500 hover:bg-rose-500/5 rounded-xl transition-all border border-transparent hover:border-rose-500/10"
|
||||
>
|
||||
Not pertinent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* History Rail */}
|
||||
<div className="w-16 border-l border-border flex flex-col items-center py-6 gap-6 bg-paper dark:bg-dark-paper z-10">
|
||||
<History size={18} className="text-concrete" />
|
||||
<div className="w-px flex-1 bg-border/40" />
|
||||
<div className="flex flex-col gap-3 overflow-y-auto px-2 custom-scrollbar">
|
||||
{sessions.map(session => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => setActiveSessionId(session.id)}
|
||||
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0
|
||||
${activeSessionId === session.id ? 'bg-ink text-paper scale-110 shadow-lg' : 'bg-paper dark:bg-white/10 text-concrete hover:bg-black/5 hover:text-ink'}`}
|
||||
title={session.seedIdea}
|
||||
>
|
||||
{session.seedIdea.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-px h-12 bg-border/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,348 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { BrainstormSession, BrainstormIdea, Note } from '../../types';
|
||||
|
||||
interface WaveCanvasProps {
|
||||
session: BrainstormSession;
|
||||
ideas: BrainstormIdea[];
|
||||
onNodeSelect: (id: string) => void;
|
||||
onPositionUpdate: (id: string, pos: { x: number; y: number }) => void;
|
||||
onAddChild: (id: string) => void;
|
||||
onManualSubmit: (title: string, parentId?: string) => void;
|
||||
onManualCancel: () => void;
|
||||
editingNodeId: string | null;
|
||||
selectedNodeId: string | null;
|
||||
relatedNotes: Note[];
|
||||
}
|
||||
|
||||
export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
session,
|
||||
ideas,
|
||||
onNodeSelect,
|
||||
onPositionUpdate,
|
||||
onAddChild,
|
||||
onManualSubmit,
|
||||
onManualCancel,
|
||||
editingNodeId,
|
||||
selectedNodeId,
|
||||
relatedNotes
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (editingNodeId && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [editingNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current) return;
|
||||
|
||||
const width = containerRef.current.clientWidth;
|
||||
const height = containerRef.current.clientHeight;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const g = svg.append("g");
|
||||
|
||||
// Zoom behavior
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 5])
|
||||
.on("zoom", (event) => {
|
||||
g.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
// Initial transform to center
|
||||
svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8));
|
||||
|
||||
// Data structures for d3
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
type: 'root' | 'idea' | 'note';
|
||||
wave?: number;
|
||||
title: string;
|
||||
color: string;
|
||||
radius: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
||||
source: string | D3Node;
|
||||
target: string | D3Node;
|
||||
type: 'wave' | 'context' | 'parent';
|
||||
}
|
||||
|
||||
const nodes: D3Node[] = [];
|
||||
const links: D3Link[] = [];
|
||||
|
||||
// Root node
|
||||
const rootNode: D3Node = {
|
||||
id: 'root',
|
||||
type: 'root',
|
||||
title: session.seedIdea,
|
||||
color: '#141414',
|
||||
radius: 40,
|
||||
fx: 0,
|
||||
fy: 0
|
||||
};
|
||||
nodes.push(rootNode);
|
||||
|
||||
// Idea nodes
|
||||
const colors = {
|
||||
1: '#fb923c', // orange
|
||||
2: '#60a5fa', // blue
|
||||
3: '#a78bfa' // violet
|
||||
};
|
||||
|
||||
ideas.forEach(idea => {
|
||||
nodes.push({
|
||||
id: idea.id,
|
||||
type: 'idea',
|
||||
wave: idea.waveNumber,
|
||||
title: idea.title,
|
||||
color: colors[idea.waveNumber as 1|2|3] || '#94a3b8',
|
||||
radius: 28,
|
||||
status: idea.status,
|
||||
x: idea.position?.x,
|
||||
y: idea.position?.y
|
||||
});
|
||||
|
||||
if (idea.parentIdeaId) {
|
||||
links.push({
|
||||
source: idea.parentIdeaId,
|
||||
target: idea.id,
|
||||
type: 'parent'
|
||||
});
|
||||
} else {
|
||||
links.push({
|
||||
source: 'root',
|
||||
target: idea.id,
|
||||
type: 'wave'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Radial layout forces
|
||||
const simulation = d3.forceSimulation<D3Node>(nodes)
|
||||
.force("link", d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(d => {
|
||||
if (d.type === 'wave') {
|
||||
const targetNode = nodes.find(n => n.id === (typeof d.target === 'string' ? d.target : (d.target as any).id));
|
||||
return (targetNode?.wave || 1) * 200;
|
||||
}
|
||||
if (d.type === 'parent') return 180;
|
||||
return 100;
|
||||
}))
|
||||
.force("charge", d3.forceManyBody().strength(-800))
|
||||
.force("radial", d3.forceRadial<D3Node>(d => {
|
||||
if (d.type === 'root') return 0;
|
||||
if (d.id.includes('-')) return (d.wave || 1) * 200 + 100; // Deepened ideas push out
|
||||
return (d.wave || 1) * 200;
|
||||
}, 0, 0).strength(0.8))
|
||||
.force("collision", d3.forceCollide<D3Node>().radius(d => d.radius + 30));
|
||||
|
||||
// Drawing rings
|
||||
const ringRadii = [200, 400, 600];
|
||||
g.selectAll(".ring")
|
||||
.data(ringRadii)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("class", "ring")
|
||||
.attr("r", d => d)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#e2e8f0")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-dasharray", "4,4")
|
||||
.style("opacity", 0.5);
|
||||
|
||||
// Links
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("stroke", d => d.type === 'wave' ? "#cbd5e1" : d.type === 'parent' ? "#fde047" : "#94a3b8")
|
||||
.attr("stroke-width", d => d.type === 'wave' ? 1.5 : 2)
|
||||
.attr("stroke-dasharray", d => d.type === 'parent' ? "none" : "4,4");
|
||||
|
||||
// Nodes
|
||||
const node = g.append("g")
|
||||
.selectAll(".node")
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "node")
|
||||
.style("opacity", d => d.status === 'dismissed' ? 0.4 : 1)
|
||||
.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
if (d.type === 'idea') onNodeSelect(d.id);
|
||||
})
|
||||
.on("dblclick", (event, d) => {
|
||||
event.stopPropagation();
|
||||
if (d.type === 'idea') {
|
||||
onNodeSelect(d.id);
|
||||
onAddChild(d.id);
|
||||
}
|
||||
})
|
||||
.call(d3.drag<SVGGElement, D3Node>()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended) as any);
|
||||
|
||||
node.append("circle")
|
||||
.attr("r", d => d.radius)
|
||||
.attr("fill", d => d.status === 'converted' ? '#ecfdf5' : (d.type === 'root' ? '#141414' : '#fff'))
|
||||
.attr("stroke", d => d.status === 'converted' ? '#10b981' : d.color)
|
||||
.attr("stroke-width", d => d.id === selectedNodeId ? 4 : 2)
|
||||
.attr("class", "cursor-pointer transition-all hover:scale-110")
|
||||
.style("filter", d => d.id === selectedNodeId ? `drop-shadow(0 0 12px ${d.color}cc)` : "none");
|
||||
|
||||
// Plus icon for selected node
|
||||
node.filter(d => d.id === selectedNodeId && d.type === 'idea')
|
||||
.append("circle")
|
||||
.attr("r", 10)
|
||||
.attr("cx", 20)
|
||||
.attr("cy", -20)
|
||||
.attr("fill", "#141414")
|
||||
.attr("stroke", "#fff")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("class", "cursor-pointer")
|
||||
.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
onAddChild(d.id);
|
||||
});
|
||||
|
||||
node.filter(d => d.id === selectedNodeId && d.type === 'idea')
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", -17)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#fff")
|
||||
.attr("class", "text-[14px] font-bold pointer-events-none")
|
||||
.text("+");
|
||||
|
||||
// ForeignObject for inline input
|
||||
const editGroup = node.filter(d => d.id === editingNodeId && d.type === 'idea')
|
||||
.append("foreignObject")
|
||||
.attr("width", 200)
|
||||
.attr("height", 80)
|
||||
.attr("x", -100)
|
||||
.attr("y", 40)
|
||||
.append("xhtml:div")
|
||||
.attr("class", "bg-paper dark:bg-black p-2 border border-border rounded-xl shadow-2xl");
|
||||
|
||||
editGroup.append("input")
|
||||
.attr("type", "text")
|
||||
.attr("placeholder", "Nouvelle idée...")
|
||||
.attr("class", "w-full bg-white dark:bg-[#1A1A1A] border-none outline-none text-xs font-bold uppercase tracking-tight p-2 rounded-lg")
|
||||
.on("keydown", (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onManualSubmit(event.target.value, editingNodeId!);
|
||||
event.target.value = '';
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
onManualCancel();
|
||||
}
|
||||
})
|
||||
.on("blur", () => {
|
||||
// Optional: onManualCancel();
|
||||
});
|
||||
|
||||
// Special case for root addition input (if editingNodeId is 'new')
|
||||
if (editingNodeId === 'new') {
|
||||
g.append("foreignObject")
|
||||
.attr("width", 200)
|
||||
.attr("height", 80)
|
||||
.attr("x", -100)
|
||||
.attr("y", -120) // Floating above center
|
||||
.append("xhtml:div")
|
||||
.attr("class", "bg-paper dark:bg-black p-2 border border-border rounded-xl shadow-2xl animate-bounce")
|
||||
.append("input")
|
||||
.attr("type", "text")
|
||||
.attr("autoFocus", "true")
|
||||
.attr("placeholder", "Idée libre...")
|
||||
.attr("class", "w-full bg-white dark:bg-[#1A1A1A] border-none outline-none text-xs font-bold uppercase tracking-tight p-2 rounded-lg")
|
||||
.on("keydown", (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onManualSubmit(event.target.value);
|
||||
event.target.value = '';
|
||||
}
|
||||
if (event.key === 'Escape') onManualCancel();
|
||||
});
|
||||
}
|
||||
|
||||
// State indicators (converted)
|
||||
node.filter(d => d.status === 'converted')
|
||||
.append("path")
|
||||
.attr("d", d3.symbol().type(d3.symbolCircle).size(150))
|
||||
.attr("fill", "#10b981");
|
||||
|
||||
// Icons/Text in nodes
|
||||
node.append("text")
|
||||
.attr("dy", d => d.type === 'root' ? ".35em" : d.radius + 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", d => d.type === 'root' ? "#fff" : (d.status === 'dismissed' ? "#94a3b8" : "#141414"))
|
||||
.attr("class", d => d.type === 'root' ? "text-[10px] font-bold pointer-events-none tracking-widest" : "text-[11px] font-bold uppercase tracking-tight pointer-events-none")
|
||||
.text(d => d.type === 'root' ? "SEED" : d.title.length > 18 ? d.title.substring(0, 18) + "..." : d.title);
|
||||
|
||||
if (rootNode) {
|
||||
g.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dy", 80)
|
||||
.attr("class", "text-2xl font-serif italic fill-ink dark:fill-dark-ink pointer-events-none shadow-sm")
|
||||
.text(session.seedIdea);
|
||||
}
|
||||
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => (d.source as any).x)
|
||||
.attr("y1", d => (d.source as any).y)
|
||||
.attr("x2", d => (d.target as any).x)
|
||||
.attr("y2", d => (d.target as any).y);
|
||||
|
||||
node
|
||||
.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
function dragstarted(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event: any, d: D3Node) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
if (d.type === 'idea') {
|
||||
onPositionUpdate(d.id, { x: event.x, y: event.y });
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
simulation.stop();
|
||||
};
|
||||
}, [session, ideas, selectedNodeId, editingNodeId, onNodeSelect]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full relative cursor-grab active:cursor-grabbing">
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
<div className="absolute top-6 left-6 pointer-events-none">
|
||||
<p className="text-[10px] font-bold tracking-[0.3em] uppercase text-concrete opacity-40">Spatial Exploration Mode</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Check,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
import { Carnet } from '../types';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
interface HierarchicalCarnetSelectorProps {
|
||||
carnets: Carnet[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const HierarchicalCarnetSelector: React.FC<HierarchicalCarnetSelectorProps> = ({
|
||||
carnets,
|
||||
selectedId,
|
||||
onSelect,
|
||||
className = "",
|
||||
placeholder = "Sélectionner un carnet..."
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(['1', '4'])); // Default expand some
|
||||
|
||||
const selectedCarnet = carnets.find(c => c.id === selectedId);
|
||||
|
||||
// Derive the path for display
|
||||
const path = useMemo(() => {
|
||||
if (!selectedCarnet) return [];
|
||||
const trail: Carnet[] = [];
|
||||
let current = selectedCarnet;
|
||||
while (current) {
|
||||
trail.unshift(current);
|
||||
if (!current.parentId) break;
|
||||
const parent = carnets.find(c => c.id === current.parentId);
|
||||
if (!parent) break;
|
||||
current = parent;
|
||||
}
|
||||
return trail;
|
||||
}, [selectedCarnet, carnets]);
|
||||
|
||||
const toggleExpand = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
const newExpanded = new Set(expandedIds);
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id);
|
||||
} else {
|
||||
newExpanded.add(id);
|
||||
}
|
||||
setExpandedIds(newExpanded);
|
||||
};
|
||||
|
||||
const filteredCarnets = useMemo(() => {
|
||||
if (!searchQuery) return carnets;
|
||||
return carnets.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}, [carnets, searchQuery]);
|
||||
|
||||
const renderTree = (parentId?: string, level = 0) => {
|
||||
const children = carnets.filter(c => c.parentId === parentId);
|
||||
if (children.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={level > 0 ? "ml-4 border-l border-border/40 pl-2" : ""}>
|
||||
{children.map(carnet => {
|
||||
const isExpanded = expandedIds.has(carnet.id) || searchQuery.length > 0;
|
||||
const hasChildren = carnets.some(c => c.parentId === carnet.id);
|
||||
const isSelected = selectedId === carnet.id;
|
||||
|
||||
// If searching and this carnet doesn't match AND none of its children match, skip it
|
||||
if (searchQuery && !carnet.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
const hasMatchingChild = (id: string): boolean => {
|
||||
const childrenOfId = carnets.filter(c => c.parentId === id);
|
||||
return childrenOfId.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()) || hasMatchingChild(c.id));
|
||||
};
|
||||
if (!hasMatchingChild(carnet.id)) return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={carnet.id} className="select-none">
|
||||
<div
|
||||
onClick={() => {
|
||||
onSelect(carnet.id);
|
||||
if (!searchQuery) setIsOpen(false);
|
||||
}}
|
||||
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
|
||||
${isSelected ? 'bg-blueprint/10 text-blueprint font-bold' : 'hover:bg-slate-50 dark:hover:bg-white/5 text-ink'}`}
|
||||
>
|
||||
<div className="w-4 flex items-center justify-center">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => toggleExpand(e, carnet.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`p-1 rounded ${isSelected ? 'bg-blueprint/20' : 'bg-slate-100 dark:bg-white/5 group-hover:bg-white/40'}`}>
|
||||
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||
</div>
|
||||
|
||||
<span className="text-[13px] truncate flex-1">{carnet.name}</span>
|
||||
|
||||
{isSelected && <Check size={14} className="opacity-60" />}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{renderTree(carnet.id, level + 1)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full bg-slate-50 dark:bg-black/20 border border-border/80 rounded-xl px-4 py-4 text-sm outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all cursor-pointer text-ink flex items-center gap-3"
|
||||
>
|
||||
<Folder size={16} className="text-blueprint/60 shrink-0" />
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
{path.length > 0 ? (
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
{path.map((item, i) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{i > 0 && <span className="text-concrete/40 text-[10px]">/</span>}
|
||||
<span className={`truncate ${i === path.length - 1 ? 'font-bold' : 'text-concrete'}`}>
|
||||
{item.name}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-concrete italic">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown size={14} className={`transition-transform duration-300 text-concrete shrink-0 ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[60]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
className="absolute z-[70] mt-2 w-full bg-white dark:bg-dark-paper border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col min-w-[280px]"
|
||||
>
|
||||
<div className="p-3 border-b border-border/40 bg-slate-50/50">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Filtrer les carnets..."
|
||||
className="w-full bg-white border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-blueprint transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto custom-scrollbar p-2">
|
||||
{renderTree(undefined)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-border/40 bg-slate-50/30 flex justify-between items-center px-4">
|
||||
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">
|
||||
Structure des carnets
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-[10px] font-bold text-blueprint hover:underline"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
248
architectural-grid12/src/components/InsightsView.tsx
Normal file
248
architectural-grid12/src/components/InsightsView.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Network,
|
||||
Lightbulb,
|
||||
Layers,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
Trophy,
|
||||
Zap,
|
||||
Tag,
|
||||
Link as LinkIcon
|
||||
} from 'lucide-react';
|
||||
import { Note, NoteCluster, BridgeNote, ConnectionSuggestion } from '../types';
|
||||
import { runClustering, detectBridges, calculateCentroid } from '../services/clusteringService';
|
||||
import { nameCluster, suggestBridgeIdeas } from '../services/geminiService';
|
||||
import { NetworkGraph } from './NetworkGraph';
|
||||
|
||||
interface InsightsViewProps {
|
||||
notes: Note[];
|
||||
onUpdateNotes: (updatedNotes: Note[]) => void;
|
||||
onNoteSelect: (noteId: string) => void;
|
||||
}
|
||||
|
||||
export const InsightsView: React.FC<InsightsViewProps> = ({
|
||||
notes,
|
||||
onUpdateNotes,
|
||||
onNoteSelect
|
||||
}) => {
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [clusters, setClusters] = useState<NoteCluster[]>([]);
|
||||
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<ConnectionSuggestion[]>([]);
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null);
|
||||
|
||||
const performAnalysis = async () => {
|
||||
setIsCalculating(true);
|
||||
try {
|
||||
// 1. Run clustering
|
||||
const { clusters: newClusters } = runClustering(notes);
|
||||
|
||||
// 2. Name clusters (first 5 unique notes per cluster)
|
||||
const namedClusters = await Promise.all(newClusters.map(async (c) => {
|
||||
const clusterNoteSummaries = notes
|
||||
.filter(n => c.noteIds.includes(n.id))
|
||||
.slice(0, 5)
|
||||
.map(n => n.title);
|
||||
|
||||
const name = await nameCluster(clusterNoteSummaries);
|
||||
const centroid = calculateCentroid(c.noteIds, notes);
|
||||
|
||||
return { ...c, name, centroid };
|
||||
}));
|
||||
|
||||
// 3. Update notes with cluster IDs
|
||||
const updatedNotes = notes.map(n => {
|
||||
const cluster = namedClusters.find(c => c.noteIds.includes(n.id));
|
||||
return { ...n, clusterId: cluster?.id };
|
||||
});
|
||||
onUpdateNotes(updatedNotes);
|
||||
|
||||
// 4. Detect bridges
|
||||
const bridges = detectBridges(updatedNotes, namedClusters);
|
||||
|
||||
// 5. Build suggestions for isolated cluster pairs
|
||||
// For demo, we'll just pick a few interesting pairs
|
||||
const newSuggestions: ConnectionSuggestion[] = [];
|
||||
if (namedClusters.length >= 2) {
|
||||
// Find clusters with no mutual bridge notes or low connectivity
|
||||
for (let i = 0; i < Math.min(namedClusters.length, 3); i++) {
|
||||
for (let j = i + 1; j < Math.min(namedClusters.length, 3); j++) {
|
||||
const cA = namedClusters[i];
|
||||
const cB = namedClusters[j];
|
||||
|
||||
const cA_notes = updatedNotes.filter(n => cA.noteIds.includes(n.id)).map(n => n.title).join(', ');
|
||||
const cB_notes = updatedNotes.filter(n => cB.noteIds.includes(n.id)).map(n => n.title).join(', ');
|
||||
|
||||
const bridgeIdeas = await suggestBridgeIdeas(cA.name, cB.name, cA_notes, cB_notes);
|
||||
bridgeIdeas.forEach((idea, idx) => {
|
||||
newSuggestions.push({
|
||||
id: `suggestion-${i}-${j}-${idx}`,
|
||||
...idea,
|
||||
clusterAId: cA.id,
|
||||
clusterBId: cB.id
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setClusters(namedClusters);
|
||||
setBridgeNotes(bridges);
|
||||
setSuggestions(newSuggestions);
|
||||
} catch (error) {
|
||||
console.error("Analysis failed:", error);
|
||||
} finally {
|
||||
setIsCalculating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (notes.some(n => n.embedding) && clusters.length === 0) {
|
||||
performAnalysis();
|
||||
}
|
||||
}, [notes]);
|
||||
|
||||
const bridgeList = useMemo(() => {
|
||||
return bridgeNotes.map(b => {
|
||||
const note = notes.find(n => n.id === b.noteId);
|
||||
return { ...b, title: note?.title || 'Unknown Note' };
|
||||
});
|
||||
}, [bridgeNotes, notes]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-paper dark:bg-[#0D0D0D] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-8 border-b border-border/40 flex items-center justify-between backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center text-indigo-500">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Semantic Insights</h1>
|
||||
</div>
|
||||
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Discovering the hidden architecture of your knowledge</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={performAnalysis}
|
||||
disabled={isCalculating}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isCalculating ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
{isCalculating ? 'Mapping...' : 'Re-sync Network'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: Graph View */}
|
||||
<div className="flex-[1.5] p-6 relative">
|
||||
<NetworkGraph
|
||||
notes={notes}
|
||||
clusters={clusters}
|
||||
bridgeNotes={bridgeNotes}
|
||||
onNoteSelect={onNoteSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Insight Dashboard */}
|
||||
<div className="flex-1 border-l border-border/40 flex flex-col h-full bg-paper/50 dark:bg-black/10 backdrop-blur-sm overflow-hidden">
|
||||
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar space-y-12">
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-indigo-500 mb-2">
|
||||
<Layers size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Clusters</span>
|
||||
</div>
|
||||
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{clusters.length}</div>
|
||||
</div>
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-ochre mb-2">
|
||||
<Trophy size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Bridge Notes</span>
|
||||
</div>
|
||||
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bridge Notes Section */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6 px-1">
|
||||
<Zap size={16} className="text-ochre" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Powerful Bridge Notes</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{bridgeList.map(bridge => (
|
||||
<motion.div
|
||||
key={bridge.noteId}
|
||||
whileHover={{ x: 4 }}
|
||||
onClick={() => onNoteSelect(bridge.noteId)}
|
||||
className="p-4 rounded-xl bg-white dark:bg-white/5 border border-border hover:border-ochre/40 transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">{bridge.title}</h4>
|
||||
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
||||
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{bridge.connectedClusterIds.map(cid => {
|
||||
const c = clusters.find(cl => cl.id === cid);
|
||||
return (
|
||||
<div key={cid} className="flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c?.color }} />
|
||||
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{c?.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{bridgeList.length === 0 && !isCalculating && (
|
||||
<div className="text-xs text-concrete italic">No significant bridge notes found yet. Deepen your research to find new connections.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connection Suggestions */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6 px-1">
|
||||
<Lightbulb size={16} className="text-indigo-500" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Missing Links (AI Generated)</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{suggestions.map((s, idx) => (
|
||||
<div key={s.id} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 to-transparent border border-indigo-500/10 hover:border-indigo-500/30 transition-all">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex -space-x-2">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[10px] text-white">A</div>
|
||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
|
||||
</div>
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">Bridging {clusters.find(c => c.id === s.clusterAId)?.name} & {clusters.find(c => c.id === s.clusterBId)?.name}</span>
|
||||
</div>
|
||||
<h4 className="text-base font-serif font-medium text-ink dark:text-dark-ink mb-2">{s.title}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.description}</p>
|
||||
<div className="p-3 bg-white/40 dark:bg-white/5 rounded-xl border border-border/40 text-[10px] italic text-concrete flex gap-2">
|
||||
<Zap size={12} className="shrink-0" />
|
||||
<span>{s.reasoning}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isCalculating && (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
173
architectural-grid12/src/components/NetworkGraph.tsx
Normal file
173
architectural-grid12/src/components/NetworkGraph.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { Note, NoteCluster, BridgeNote } from '../types';
|
||||
|
||||
interface NetworkGraphProps {
|
||||
notes: Note[];
|
||||
clusters: NoteCluster[];
|
||||
bridgeNotes: BridgeNote[];
|
||||
onNoteSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export const NetworkGraph: React.FC<NetworkGraphProps> = ({
|
||||
notes,
|
||||
clusters,
|
||||
bridgeNotes,
|
||||
onNoteSelect
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current) return;
|
||||
|
||||
const width = containerRef.current.clientWidth;
|
||||
const height = containerRef.current.clientHeight;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const g = svg.append("g");
|
||||
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on("zoom", (event) => {
|
||||
g.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
// Filter notes with embeddings and cluster assignments
|
||||
const visibleNotes = notes.filter(n => n.embedding && n.clusterId);
|
||||
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
title: string;
|
||||
clusterId: string;
|
||||
color: string;
|
||||
isBridge: boolean;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
||||
source: string;
|
||||
target: string;
|
||||
strength: number;
|
||||
}
|
||||
|
||||
const bridgeSet = new Set(bridgeNotes.map(b => b.noteId));
|
||||
|
||||
const nodes: D3Node[] = visibleNotes.map(n => {
|
||||
const cluster = clusters.find(c => c.id === n.clusterId);
|
||||
const isBridge = bridgeSet.has(n.id);
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
clusterId: n.clusterId!,
|
||||
color: cluster?.color || '#cbd5e1',
|
||||
isBridge,
|
||||
radius: isBridge ? 12 : 8
|
||||
};
|
||||
});
|
||||
|
||||
const links: D3Link[] = [];
|
||||
// Only connect strong links
|
||||
for (let i = 0; i < visibleNotes.length; i++) {
|
||||
for (let j = i + 1; j < visibleNotes.length; j++) {
|
||||
const ni = visibleNotes[i];
|
||||
const nj = visibleNotes[j];
|
||||
|
||||
if (ni.clusterId === nj.clusterId) {
|
||||
links.push({ source: ni.id, target: nj.id, strength: 0.5 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const simulation = d3.forceSimulation<D3Node>(nodes)
|
||||
.force("link", d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(100))
|
||||
.force("charge", d3.forceManyBody().strength(-200))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collision", d3.forceCollide<D3Node>().radius(d => d.radius + 10));
|
||||
|
||||
// Links
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("stroke", "#e2e8f0")
|
||||
.attr("stroke-opacity", 0.6)
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Nodes
|
||||
const node = g.append("g")
|
||||
.selectAll(".node")
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "node cursor-pointer")
|
||||
.on("click", (event, d) => onNoteSelect(d.id))
|
||||
.call(d3.drag<SVGGElement, D3Node>()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended) as any);
|
||||
|
||||
node.append("circle")
|
||||
.attr("r", d => d.radius)
|
||||
.attr("fill", d => d.color)
|
||||
.attr("stroke", d => d.isBridge ? "#D4AF37" : "#fff")
|
||||
.attr("stroke-width", d => d.isBridge ? 3 : 2)
|
||||
.style("filter", d => d.isBridge ? "drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))" : "none");
|
||||
|
||||
node.append("text")
|
||||
.attr("dy", d => d.radius + 14)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("class", "text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none")
|
||||
.text(d => d.title.length > 20 ? d.title.substring(0, 20) + "..." : d.title);
|
||||
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => (d.source as any).x)
|
||||
.attr("y1", d => (d.source as any).y)
|
||||
.attr("x2", d => (d.target as any).x)
|
||||
.attr("y2", d => (d.target as any).y);
|
||||
|
||||
node
|
||||
.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
function dragstarted(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event: any, d: D3Node) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
|
||||
return () => simulation.stop();
|
||||
}, [notes, clusters, bridgeNotes, onNoteSelect]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
|
||||
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-3 max-w-[300px]">
|
||||
{clusters.map(c => (
|
||||
<div key={c.id} className="flex items-center gap-1.5 px-2 py-1 bg-white/80 dark:bg-white/5 backdrop-blur-sm border border-border rounded-full shadow-sm">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-concrete whitespace-nowrap">{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
489
architectural-grid12/src/components/NotebooksView.tsx
Normal file
489
architectural-grid12/src/components/NotebooksView.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Pin,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
MoreVertical,
|
||||
Sparkles,
|
||||
Tag as TagIcon,
|
||||
X,
|
||||
BookOpen,
|
||||
Edit3,
|
||||
Eye,
|
||||
Trash2,
|
||||
Wind
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Note, Carnet, Tag } from '../types';
|
||||
import { SlashMenu } from './SlashMenu';
|
||||
|
||||
interface NotebooksViewProps {
|
||||
activeNoteId: string | null;
|
||||
activeCarnet: Carnet | undefined;
|
||||
filteredNotes: Note[];
|
||||
activeNote: Note | undefined;
|
||||
setActiveNoteId: (id: string | null) => void;
|
||||
togglePin: (id: string) => void;
|
||||
setShowNewNoteModal: (show: boolean) => void;
|
||||
isAISidebarOpen: boolean;
|
||||
setIsAISidebarOpen: (open: boolean) => void;
|
||||
selectedTagIds: string[];
|
||||
setSelectedTagIds: (ids: string[]) => void;
|
||||
allNotes: Note[];
|
||||
activeCarnetId: string;
|
||||
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
|
||||
onDeleteNote: (id: string) => void;
|
||||
onBrainstormNote: (note: Note) => void;
|
||||
}
|
||||
|
||||
export const NotebooksView: React.FC<NotebooksViewProps> = ({
|
||||
activeNoteId,
|
||||
activeCarnet,
|
||||
filteredNotes,
|
||||
activeNote,
|
||||
setActiveNoteId,
|
||||
togglePin,
|
||||
setShowNewNoteModal,
|
||||
isAISidebarOpen,
|
||||
setIsAISidebarOpen,
|
||||
selectedTagIds,
|
||||
setSelectedTagIds,
|
||||
allNotes,
|
||||
activeCarnetId,
|
||||
setShowNewCarnetModal,
|
||||
onDeleteNote,
|
||||
onBrainstormNote
|
||||
}) => {
|
||||
const [isTagsExpanded, setIsTagsExpanded] = React.useState(false);
|
||||
const [tagSearchQuery, setTagSearchQuery] = React.useState('');
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [slashMenu, setSlashMenu] = React.useState<{ isOpen: boolean; top: number; left: number } | null>(null);
|
||||
|
||||
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === '/') {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
setSlashMenu({
|
||||
isOpen: true,
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const insertCommand = (type: string) => {
|
||||
console.log(`Command selected: ${type}`);
|
||||
setSlashMenu(null);
|
||||
};
|
||||
|
||||
const availableTags = React.useMemo(() => {
|
||||
const carnetNotes = allNotes.filter(n => n.carnetId === activeCarnetId);
|
||||
const tagsMap = new Map<string, Tag>();
|
||||
carnetNotes.forEach(note => {
|
||||
note.tags?.forEach(tag => {
|
||||
tagsMap.set(tag.id, tag);
|
||||
});
|
||||
});
|
||||
return Array.from(tagsMap.values()).sort((a, b) => {
|
||||
// AI tags first, then alphabetical
|
||||
if (a.type === 'ai' && b.type !== 'ai') return -1;
|
||||
if (a.type !== 'ai' && b.type === 'ai') return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}, [allNotes, activeCarnetId]);
|
||||
|
||||
const visibleTags = React.useMemo(() => {
|
||||
let filtered = availableTags;
|
||||
if (tagSearchQuery) {
|
||||
filtered = availableTags.filter(t =>
|
||||
t.label.toLowerCase().includes(tagSearchQuery.toLowerCase())
|
||||
);
|
||||
} else if (!isTagsExpanded) {
|
||||
filtered = availableTags.slice(0, 10);
|
||||
// Ensure selected tags are always visible even if not in the first 10
|
||||
selectedTagIds.forEach(id => {
|
||||
if (!filtered.find(t => t.id === id)) {
|
||||
const tag = availableTags.find(t => t.id === id);
|
||||
if (tag) filtered.push(tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, [availableTags, isTagsExpanded, tagSearchQuery, selectedTagIds]);
|
||||
|
||||
const toggleTag = (tagId: string) => {
|
||||
if (selectedTagIds.includes(tagId)) {
|
||||
setSelectedTagIds(selectedTagIds.filter(id => id !== tagId));
|
||||
} else {
|
||||
setSelectedTagIds([...selectedTagIds, tagId]);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeNoteId) {
|
||||
return (
|
||||
<div 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
|
||||
onClick={() => setShowNewCarnetModal(true, activeCarnetId)}
|
||||
className="flex items-center gap-2 text-[13px] text-concrete font-medium hover:text-ink transition-all"
|
||||
>
|
||||
<BookOpen size={16} />
|
||||
<span>New Sub-Carnet</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>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-[0.2em] text-concrete">
|
||||
<TagIcon size={12} />
|
||||
<span>Filter by Tags</span>
|
||||
{selectedTagIds.length > 0 && (
|
||||
<span className="bg-blueprint/10 text-blueprint px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
|
||||
{selectedTagIds.length} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{availableTags.length > 10 && (
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
className="bg-transparent border-b border-border/40 text-[10px] outline-none focus:border-blueprint/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-concrete/40"
|
||||
onChange={(e) => setTagSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center min-h-[32px]">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visibleTags.map(tag => {
|
||||
const isActive = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<motion.button
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all border flex items-center gap-2
|
||||
${isActive
|
||||
? 'bg-ink text-paper border-ink shadow-lg shadow-ink/10'
|
||||
: 'bg-white/40 border-border text-concrete hover:border-concrete/40 hover:bg-white/60'}`}
|
||||
>
|
||||
{tag.type === 'ai' && (
|
||||
<Sparkles
|
||||
size={10}
|
||||
className={isActive ? 'text-blueprint' : 'text-blueprint/60'}
|
||||
/>
|
||||
)}
|
||||
{tag.label}
|
||||
{isActive && <X size={10} />}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{availableTags.length > 10 && !tagSearchQuery && (
|
||||
<button
|
||||
onClick={() => setIsTagsExpanded(!isTagsExpanded)}
|
||||
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-concrete/60 hover:text-ink transition-colors border border-dashed border-border rounded-full"
|
||||
>
|
||||
{isTagsExpanded ? 'Show less' : `+ ${availableTags.length - 10} more`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedTagIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedTagIds([])}
|
||||
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-rust hover:underline ml-auto"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</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 relative"
|
||||
onClick={() => setActiveNoteId(note.id)}
|
||||
>
|
||||
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
|
||||
<span className="flex items-center gap-3">
|
||||
{note.isPinned && <Pin size={18} className="text-amber-500 fill-amber-500" />}
|
||||
{note.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onBrainstormNote(note);
|
||||
}}
|
||||
className="p-2 rounded-full opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-ochre/10 text-ochre transition-all"
|
||||
title="Brainstorm this concept"
|
||||
>
|
||||
<Wind size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePin(note.id);
|
||||
}}
|
||||
className={`p-2 rounded-full transition-all ${note.isPinned ? 'text-amber-600 bg-amber-50' : 'opacity-0 group-hover:opacity-60 hover:bg-slate-100 text-ink'}`}
|
||||
>
|
||||
<Pin size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteNote(note.id);
|
||||
}}
|
||||
className="p-2 rounded-full opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-rose-50 text-rose-500 transition-all"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</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 dark:bg-white/5 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">
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{note.tags?.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border flex items-center gap-1.5
|
||||
${tag.type === 'ai'
|
||||
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
||||
: 'bg-concrete/5 border-border text-concrete'}`}
|
||||
>
|
||||
{tag.type === 'ai' && <Sparkles size={8} />}
|
||||
{tag.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex overflow-hidden transition-all duration-500">
|
||||
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-paper">
|
||||
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 dark:bg-paper/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={() => onBrainstormNote(activeNote!)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-ochre/30 text-ochre hover:bg-ochre/5 transition-all"
|
||||
>
|
||||
<Wind size={16} />
|
||||
<span className="text-xs font-bold uppercase tracking-widest">Brainstorm</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg border transition-all duration-300
|
||||
${isEditing ? 'bg-blueprint text-white border-blueprint shadow-lg shadow-blueprint/20' : 'border-border text-ink hover:bg-slate-50'}`}
|
||||
>
|
||||
{isEditing ? <Eye size={16} /> : <Edit3 size={16} />}
|
||||
<span className="text-xs font-bold uppercase tracking-widest">{isEditing ? 'Visualiser' : 'Modifier'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => togglePin(activeNoteId!)}
|
||||
className={`p-2 rounded-full transition-all ${activeNote?.isPinned ? 'text-amber-600 bg-amber-50 dark:bg-ochre/10' : 'text-muted-ink hover:text-ink'}`}
|
||||
title={activeNote?.isPinned ? "Unpin note" : "Pin note"}
|
||||
>
|
||||
<Pin size={18} className={activeNote?.isPinned ? 'fill-amber-600' : ''} />
|
||||
</button>
|
||||
<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 dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
<span className="text-xs font-medium">AI Assistant</span>
|
||||
</button>
|
||||
<button className="p-2 text-muted-ink hover:text-red-500 transition-colors">
|
||||
<Trash2 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 relative">
|
||||
<AnimatePresence>
|
||||
{slashMenu?.isOpen && (
|
||||
<SlashMenu
|
||||
position={{ top: slashMenu.top, left: slashMenu.left }}
|
||||
onSelect={(type) => insertCommand(type)}
|
||||
onClose={() => setSlashMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
|
||||
<span className="text-blueprint">{activeCarnet?.name}</span>
|
||||
<ChevronRight size={10} className="text-concrete" />
|
||||
<span className="text-concrete">{activeNote?.date}</span>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={activeNote?.title}
|
||||
className="w-full text-5xl md:text-6xl font-serif font-bold text-ink leading-tight bg-transparent border-none outline-none focus:ring-0 placeholder:text-concrete/20"
|
||||
placeholder="Titre de la note..."
|
||||
/>
|
||||
) : (
|
||||
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
|
||||
{activeNote?.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{activeNote?.tags?.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border flex items-center gap-2
|
||||
${tag.type === 'ai'
|
||||
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
||||
: 'bg-paper border-border text-concrete'}`}
|
||||
>
|
||||
{tag.type === 'ai' && <Sparkles size={12} />}
|
||||
{tag.label}
|
||||
{tag.type === 'ai' && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blueprint animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-white/5 rounded-xl overflow-hidden shadow-2xl relative group/img">
|
||||
<img
|
||||
src={activeNote?.imageUrl}
|
||||
alt={activeNote?.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover/img:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-ink/20 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto w-full space-y-8 pb-40">
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
defaultValue={activeNote?.content}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
className="w-full min-h-[500px] text-lg leading-relaxed text-ink/90 font-serif bg-transparent border-none outline-none focus:ring-0 resize-none placeholder:text-concrete/20"
|
||||
placeholder="Commencez à écrire... Tapez '/' pour les commandes."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<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" />
|
||||
<div className="space-y-6">
|
||||
{activeNote?.content.split('\n').map((line, i) => (
|
||||
<p key={i} className="text-lg leading-relaxed text-ink/80 font-light text-justify selection:bg-blueprint/20">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
{activeNote?.id.startsWith('n-') && (
|
||||
<p className="text-lg leading-relaxed text-ink/80 font-light text-justify border-l-2 border-blueprint/20 pl-6 italic">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
architectural-grid12/src/components/SettingsView.tsx
Normal file
66
architectural-grid12/src/components/SettingsView.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { SettingsTab } from '../types';
|
||||
import { SettingsHeader } from './settings/SettingsHeader';
|
||||
import { GeneralTab } from './settings/GeneralTab';
|
||||
import { AITab } from './settings/AITab';
|
||||
import { AppearanceTab } from './settings/AppearanceTab';
|
||||
|
||||
interface SettingsViewProps {
|
||||
activeSettingsTab: SettingsTab;
|
||||
setActiveSettingsTab: (tab: SettingsTab) => void;
|
||||
}
|
||||
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
activeSettingsTab,
|
||||
setActiveSettingsTab
|
||||
}) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-paper dark:bg-dark-paper overflow-y-auto custom-scrollbar relative font-sans">
|
||||
<div className="absolute inset-0 opacity-[0.04] pointer-events-none grainy-bg mix-blend-multiply dark:mix-blend-overlay" />
|
||||
|
||||
<div className="relative z-10 flex flex-col min-h-full">
|
||||
<SettingsHeader
|
||||
activeTab={activeSettingsTab}
|
||||
setActiveTab={setActiveSettingsTab}
|
||||
/>
|
||||
|
||||
<div className="flex-1 px-12 pb-24 h-full">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeSettingsTab === 'general' && (
|
||||
<GeneralTab key="general" />
|
||||
)}
|
||||
|
||||
{activeSettingsTab === 'ai' && (
|
||||
<AITab key="ai" />
|
||||
)}
|
||||
|
||||
{activeSettingsTab === 'appearance' && (
|
||||
<AppearanceTab key="appearance" />
|
||||
)}
|
||||
|
||||
{['profile', 'data', 'mcp', 'about'].includes(activeSettingsTab) && (
|
||||
<motion.div
|
||||
key="placeholder"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="h-[50vh] flex flex-col items-center justify-center border border-dashed border-border rounded-[32px] space-y-6 bg-white/20 dark:bg-white/5"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-3xl border border-dashed border-concrete/20 flex items-center justify-center text-concrete/40 bg-paper/50">
|
||||
<span className="text-2xl font-serif italic text-concrete">?</span>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-ink font-bold text-sm tracking-tight">Section en développement</p>
|
||||
<p className="text-concrete italic text-[11px] font-light">Le module {activeSettingsTab} sera disponible prochainement.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
569
architectural-grid12/src/components/Sidebar.tsx
Normal file
569
architectural-grid12/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Archive,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Microscope,
|
||||
Activity,
|
||||
Pin,
|
||||
Moon,
|
||||
Sun,
|
||||
Bell,
|
||||
Lock,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Users,
|
||||
Clock,
|
||||
GripVertical,
|
||||
Wind,
|
||||
Network
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { NavigationView, Carnet, Note } from '../types';
|
||||
|
||||
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 dark:bg-white/10 text-ink font-medium' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 truncate">
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${isActive ? 'bg-ink' : 'bg-transparent border border-muted-ink/30'}`} />
|
||||
<span className="truncate">{note.title}</span>
|
||||
</div>
|
||||
{note.isPinned && <Pin size={10} className="text-amber-500 shrink-0" />}
|
||||
</motion.button>
|
||||
);
|
||||
|
||||
interface SidebarItemProps {
|
||||
carnet: Carnet;
|
||||
isActive: boolean;
|
||||
notes: Note[];
|
||||
activeNoteId: string | null;
|
||||
onCarnetClick: () => void;
|
||||
onNoteClick: (noteId: string) => void;
|
||||
onAddSubCarnet: () => void;
|
||||
onRename: () => void;
|
||||
onDelete: () => void;
|
||||
children?: React.ReactNode;
|
||||
level: number;
|
||||
isExpanded: boolean;
|
||||
toggleExpand: () => void;
|
||||
onMove?: (draggedId: string, targetId?: string) => void;
|
||||
}
|
||||
|
||||
const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
carnet,
|
||||
isActive,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onCarnetClick,
|
||||
onNoteClick,
|
||||
onAddSubCarnet,
|
||||
onRename,
|
||||
onDelete,
|
||||
children,
|
||||
level,
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
onMove
|
||||
}) => {
|
||||
const hasChildren = React.Children.count(children) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div
|
||||
className="flex items-center group relative h-10"
|
||||
style={{ paddingLeft: `${level * 12}px` }}
|
||||
>
|
||||
{/* Subtle Drag Handle */}
|
||||
<div className="absolute left-[-2px] opacity-0 group-hover:opacity-40 cursor-grab active:cursor-grabbing text-concrete transition-opacity z-10">
|
||||
<GripVertical size={10} />
|
||||
</div>
|
||||
|
||||
{/* Hierarchy Guide Line */}
|
||||
{level > 0 && (
|
||||
<div className="absolute left-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
|
||||
)}
|
||||
{level > 0 && (
|
||||
<div className="absolute left-[8px] top-1/2 w-[8px] h-px bg-border/40" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand();
|
||||
}}
|
||||
className="p-1 hover:bg-ink/5 dark:hover:bg-white/5 rounded-md transition-colors text-muted-ink"
|
||||
>
|
||||
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
||||
<ChevronRight size={14} />
|
||||
</motion.div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6" /> // Spacer for alignment
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
whileHover={{ x: 2 }}
|
||||
className={`flex-1 flex items-center gap-2 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative
|
||||
${isActive ? 'bg-white shadow-sm border border-border/40 dark:bg-white/10' : 'hover:bg-white/40 dark:hover:bg-white/5'}`}
|
||||
onClick={onCarnetClick}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.currentTarget.classList.add('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
const draggedId = e.dataTransfer.getData('carnetId');
|
||||
console.log('Dropped carnet:', draggedId, 'on target:', carnet.id);
|
||||
if (draggedId && draggedId !== carnet.id) {
|
||||
onMove?.(draggedId, carnet.id);
|
||||
}
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
console.log('Starting drag for carnet:', carnet.id);
|
||||
e.dataTransfer.setData('carnetId', carnet.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
const ghost = e.currentTarget.cloneNode(true) as HTMLElement;
|
||||
ghost.style.position = 'absolute';
|
||||
ghost.style.top = '-1000px';
|
||||
ghost.style.opacity = '0.5';
|
||||
document.body.appendChild(ghost);
|
||||
e.dataTransfer.setDragImage(ghost, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(ghost), 0);
|
||||
}}
|
||||
>
|
||||
{/* active indicator dot */}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-indicator"
|
||||
className="absolute -left-1 w-1 h-4 bg-blueprint rounded-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border transition-all
|
||||
${isActive ? 'bg-blueprint text-white border-blueprint' : 'bg-paper dark:bg-white/10 text-concrete border-border dark:border-white/10'}`}>
|
||||
{carnet.initial}
|
||||
</div>
|
||||
<div className="flex-1 text-left flex items-center gap-2 min-w-0">
|
||||
<span className={`text-[12px] font-medium transition-colors truncate ${isActive ? 'text-ink' : 'text-muted-ink group-hover:text-ink'}`}>
|
||||
{carnet.name}
|
||||
</span>
|
||||
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddSubCarnet();
|
||||
}}
|
||||
className="p-1 hover:bg-ink/10 dark:hover:bg-white/10 rounded-md transition-all text-concrete hover:text-ink"
|
||||
title="Add sub-carnet"
|
||||
>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRename();
|
||||
}}
|
||||
className="p-1 hover:bg-ink/10 dark:hover:bg-white/10 rounded-md transition-all text-concrete hover:text-ink"
|
||||
title="Rename"
|
||||
>
|
||||
<Edit3 size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-all text-concrete hover:text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
|
||||
{notes.length > 0 && (
|
||||
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover:text-concrete transition-colors">
|
||||
{notes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{(isExpanded || (isActive && !hasChildren)) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative" style={{ marginLeft: `${(level + 1) * 12 + 10}px` }}>
|
||||
{/* Vertical line for nested content */}
|
||||
<div className="absolute left-[-6px] top-0 bottom-4 w-px bg-border/30" />
|
||||
|
||||
<div className="space-y-1 py-1">
|
||||
{children}
|
||||
{isActive && !hasChildren && notes.map(note => (
|
||||
<NoteLink
|
||||
key={note.id}
|
||||
note={note}
|
||||
isActive={activeNoteId === note.id}
|
||||
onClick={() => onNoteClick(note.id)}
|
||||
/>
|
||||
))}
|
||||
{isActive && !hasChildren && notes.length === 0 && (
|
||||
<p className="pl-8 py-2 text-[10px] italic text-concrete/40 font-light">
|
||||
No notes found
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
activeView: NavigationView;
|
||||
isDarkMode: boolean;
|
||||
setIsDarkMode: (val: boolean) => void;
|
||||
setActiveView: (view: NavigationView) => void;
|
||||
carnets: Carnet[];
|
||||
notes: Note[];
|
||||
activeCarnetId: string;
|
||||
activeNoteId: string | null;
|
||||
setActiveCarnetId: (id: string) => void;
|
||||
setActiveNoteId: (id: string | null) => void;
|
||||
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
|
||||
onDeleteCarnet: (id: string) => void;
|
||||
onMoveCarnet: (draggedId: string, targetId?: string) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
activeView,
|
||||
isDarkMode,
|
||||
setIsDarkMode,
|
||||
setActiveView,
|
||||
carnets,
|
||||
notes,
|
||||
activeCarnetId,
|
||||
activeNoteId,
|
||||
setActiveCarnetId,
|
||||
setActiveNoteId,
|
||||
setShowNewCarnetModal,
|
||||
onDeleteCarnet,
|
||||
onMoveCarnet
|
||||
}) => {
|
||||
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set(['4'])); // Default expand Research
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
const newSet = new Set(expandedIds);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
setExpandedIds(newSet);
|
||||
};
|
||||
|
||||
const renderCarnetTree = (parentId: string | undefined = undefined, level: number = 0) => {
|
||||
return carnets
|
||||
.filter(c => c.parentId === parentId && !c.isDeleted)
|
||||
.map(carnet => (
|
||||
<SidebarItem
|
||||
key={carnet.id}
|
||||
carnet={carnet}
|
||||
isActive={activeCarnetId === carnet.id}
|
||||
notes={notes.filter(n => n.carnetId === carnet.id && !n.isDeleted)}
|
||||
activeNoteId={activeNoteId}
|
||||
level={level}
|
||||
isExpanded={expandedIds.has(carnet.id)}
|
||||
toggleExpand={() => toggleExpand(carnet.id)}
|
||||
onAddSubCarnet={() => {
|
||||
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
|
||||
setShowNewCarnetModal(true, carnet.id);
|
||||
}}
|
||||
onRename={() => {
|
||||
setShowNewCarnetModal(true, undefined, true, carnet.id);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDeleteCarnet(carnet.id);
|
||||
}}
|
||||
onCarnetClick={() => {
|
||||
setActiveCarnetId(carnet.id);
|
||||
setActiveNoteId(null);
|
||||
// Auto expand when clicking
|
||||
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
|
||||
}}
|
||||
onNoteClick={(id) => {
|
||||
setActiveCarnetId(carnet.id);
|
||||
setActiveNoteId(id);
|
||||
}}
|
||||
onMove={onMoveCarnet}
|
||||
>
|
||||
{renderCarnetTree(carnet.id, level + 1)}
|
||||
</SidebarItem>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-80 bg-white/30 dark:bg-[#151515] backdrop-blur-md border-r border-border p-6 flex flex-col z-20 shrink-0 transition-colors duration-500">
|
||||
<div className="mb-10 flex items-center justify-between">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-200 dark:bg-white/10 border border-border flex items-center justify-center text-ink font-serif text-lg shadow-sm">
|
||||
A
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
className="p-2 text-muted-ink hover:text-ink transition-all bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10"
|
||||
>
|
||||
{isDarkMode ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
|
||||
<button className="p-2 text-muted-ink hover:text-ink transition-all relative group bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10">
|
||||
<Bell size={14} />
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-rose-500 text-white text-[9px] font-bold flex items-center justify-center rounded-full border border-white shadow-sm">
|
||||
3
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex bg-white/50 dark:bg-white/10 p-1 rounded-full border border-border dark:border-white/10 transition-all">
|
||||
<button
|
||||
onClick={() => setActiveView('notebooks')}
|
||||
className={`p-1.5 rounded-full transition-all ${activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
|
||||
title="Carnets"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('reminders')}
|
||||
className={`p-1.5 rounded-full transition-all ${activeView === 'reminders' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
|
||||
title="Rappels"
|
||||
>
|
||||
<Clock size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('agents')}
|
||||
className={`p-1.5 rounded-full transition-all ${activeView === 'agents' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
|
||||
title="Agents"
|
||||
>
|
||||
<Bot size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-8 -mx-2 px-2 py-4 custom-scrollbar">
|
||||
{activeView === 'notebooks' ? (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-1 rounded-lg transition-colors group/header"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.currentTarget.classList.add('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
const draggedId = e.dataTransfer.getData('carnetId');
|
||||
console.log('Dropped carnet on root:', draggedId);
|
||||
if (draggedId) {
|
||||
onMoveCarnet(draggedId, undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">
|
||||
Architecture Grid
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewCarnetModal(true)}
|
||||
className="p-1 hover:bg-paper dark:hover:bg-white/5 rounded-md text-concrete hover:text-ink transition-colors"
|
||||
title="New Carnet"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-0.5">
|
||||
{renderCarnetTree()}
|
||||
</nav>
|
||||
</div>
|
||||
) : activeView === 'shared' ? (
|
||||
<div className="space-y-6">
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4">
|
||||
Partagé avec moi
|
||||
</p>
|
||||
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
|
||||
<Users size={24} className="mx-auto text-concrete/40 mb-3" />
|
||||
<p className="text-[11px] text-concrete italic">Aucun document partagé pour le moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : activeView === 'reminders' ? (
|
||||
<div className="space-y-6">
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4">
|
||||
Rappels programmés
|
||||
</p>
|
||||
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
|
||||
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
|
||||
<p className="text-[11px] text-concrete italic">Aucun rappel actif.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : activeView === 'agents' ? (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-muted-ink tracking-widest uppercase mb-4 px-4">
|
||||
Intelligence OS
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ id: 'a1', name: 'Mes Agents', icon: <Bot size={16} /> },
|
||||
{ id: 'a2', name: 'Le Lab AI', icon: <Microscope size={16} /> },
|
||||
{ id: 'a3', name: 'Activités', icon: <Activity size={16} /> },
|
||||
].map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
|
||||
${item.id === 'a1' ? 'active-nav-item' : 'text-muted-ink hover:bg-white/40 dark:hover:bg-white/5 hover:text-ink'}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center border transition-colors
|
||||
${item.id === 'a1' ? 'bg-ink text-paper border-ink' : 'bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-ink/20'}`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-[13px] font-medium">{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-1">
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4 mb-2">
|
||||
Capabilities
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveView('brainstorm')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-ochre hover:bg-ochre/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-ochre/20">
|
||||
<Wind size={16} />
|
||||
</div>
|
||||
<span className="flex-1 text-left">Brainstorm Wave</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('insights')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-indigo-500 hover:bg-indigo-500/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-indigo-500/20">
|
||||
<Network size={16} />
|
||||
</div>
|
||||
<span className="flex-1 text-left">Semantic Network</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('temporal')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-rose-500 hover:bg-rose-500/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-rose-500/20">
|
||||
<Clock size={16} />
|
||||
</div>
|
||||
<span className="flex-1 text-left">Temporal Forecast</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border/40 mt-auto pb-4">
|
||||
<div className="px-2 space-y-0.5">
|
||||
<button
|
||||
onClick={() => setActiveView('shared')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'shared' ? 'bg-blueprint/5 text-blueprint' : 'text-muted-ink hover:text-ink hover:bg-black/5'}`}
|
||||
>
|
||||
<Users size={14} className={activeView === 'shared' ? 'text-blueprint' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<span className="flex-1 text-left">Partagé</span>
|
||||
</button>
|
||||
|
||||
<button className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-black/5 transition-all font-medium group rounded-xl">
|
||||
<Archive size={14} className="text-muted-ink group-hover:text-ink" />
|
||||
<span className="flex-1 text-left">Archives</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('trash')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'trash' ? 'bg-rose-50 text-rose-500' : 'text-muted-ink hover:text-rose-500 hover:bg-rose-50/50'}`}
|
||||
>
|
||||
<Trash2 size={14} className={activeView === 'trash' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
|
||||
<span className="flex-1 text-left">Corbeille</span>
|
||||
{notes.some(n => n.isDeleted) && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-rose-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="my-4 pt-4 border-t border-border/20">
|
||||
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase px-3 mb-2 opacity-60">Intelligence</p>
|
||||
<button
|
||||
onClick={() => setActiveView('brainstorm')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'brainstorm' ? 'bg-ochre/10 text-ochre' : 'text-muted-ink hover:text-ochre hover:bg-ochre/5'}`}
|
||||
>
|
||||
<Wind size={14} className={activeView === 'brainstorm' ? 'text-ochre' : 'text-muted-ink group-hover:text-ochre'} />
|
||||
<span className="flex-1 text-left">Brainstorm Wave</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('insights')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'insights' ? 'bg-indigo-500/10 text-indigo-500' : 'text-muted-ink hover:text-indigo-500 hover:bg-indigo-500/5'}`}
|
||||
>
|
||||
<Network size={14} className={activeView === 'insights' ? 'text-indigo-500' : 'text-muted-ink group-hover:text-indigo-500'} />
|
||||
<span className="flex-1 text-left">Semantic Network</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('temporal')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'temporal' ? 'bg-rose-500/10 text-rose-500' : 'text-muted-ink hover:text-rose-500 hover:bg-rose-500/5'}`}
|
||||
>
|
||||
<Clock size={14} className={activeView === 'temporal' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
|
||||
<span className="flex-1 text-left">Temporal Forecast</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="my-2 h-px bg-border/20 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('settings')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'settings' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-black/5'}`}
|
||||
>
|
||||
<Settings size={14} className={activeView === 'settings' ? 'text-paper' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<span className="flex-1 text-left">Paramètres</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
65
architectural-grid12/src/components/SlashMenu.tsx
Normal file
65
architectural-grid12/src/components/SlashMenu.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
List,
|
||||
Quote,
|
||||
Code,
|
||||
Image as ImageIcon,
|
||||
Type,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
interface SlashMenuProps {
|
||||
position: { top: number; left: number };
|
||||
onSelect: (type: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SlashMenu: React.FC<SlashMenuProps> = ({ position, onSelect, onClose }) => {
|
||||
const commands = [
|
||||
{ id: 'h1', label: 'Titre Principal', icon: <Heading1 size={14} />, desc: 'Grand titre de section' },
|
||||
{ id: 'h2', label: 'Sous-titre', icon: <Heading2 size={14} />, desc: 'Titre de niveau 2' },
|
||||
{ id: 'bullet', label: 'Liste à puces', icon: <List size={14} />, desc: 'Liste simple' },
|
||||
{ id: 'quote', label: 'Citation', icon: <Quote size={14} />, desc: 'Bloc de texte mis en avant' },
|
||||
{ id: 'code', label: 'Bloc de Code', icon: <Code size={14} />, desc: 'Code ou texte technique' },
|
||||
{ id: 'image', label: 'Image', icon: <ImageIcon size={14} />, desc: 'Insérer un visuel' },
|
||||
{ id: 'ai-summary', label: 'Résumé IA', icon: <Sparkles size={14} />, desc: 'Générer un résumé court', special: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[60]" onClick={onClose} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
className="fixed z-[70] w-64 bg-white dark:bg-[#1A1A1A] border border-border shadow-2xl rounded-xl overflow-hidden py-2"
|
||||
style={{ top: position.top, left: position.left }}
|
||||
>
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-concrete uppercase tracking-widest border-b border-border/40 mb-1">
|
||||
Commandes rapides
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto custom-scrollbar">
|
||||
{commands.map((cmd) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
onClick={() => onSelect(cmd.id)}
|
||||
className="w-full flex items-start gap-3 px-3 py-2 hover:bg-paper dark:hover:bg-white/5 transition-colors group text-left"
|
||||
>
|
||||
<div className={`p-2 rounded-lg border border-border transition-colors group-hover:border-ink/20
|
||||
${cmd.special ? 'bg-blueprint/10 text-blueprint border-blueprint/20' : 'bg-white/50 dark:bg-white/5 text-ink'}`}>
|
||||
{cmd.icon}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-bold text-ink">{cmd.label}</p>
|
||||
<p className="text-[10px] text-muted-ink leading-tight">{cmd.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
169
architectural-grid12/src/components/TemporalView.tsx
Normal file
169
architectural-grid12/src/components/TemporalView.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Clock,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
Zap,
|
||||
History,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
PieChart
|
||||
} from 'lucide-react';
|
||||
import { Note, NoteAccessLog, NotePrediction } from '../types';
|
||||
import { predictNextAccess, detectAccessCycle } from '../services/temporalService';
|
||||
|
||||
interface TemporalViewProps {
|
||||
notes: Note[];
|
||||
accessLogs: NoteAccessLog[];
|
||||
onNoteSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export const TemporalView: React.FC<TemporalViewProps> = ({ notes, accessLogs, onNoteSelect }) => {
|
||||
const predictions = useMemo(() => {
|
||||
return notes
|
||||
.map(note => {
|
||||
const noteLogs = accessLogs.filter(l => l.noteId === note.id);
|
||||
const prediction = predictNextAccess(note, noteLogs);
|
||||
return { note, prediction };
|
||||
})
|
||||
.filter(p => p.prediction !== null) as { note: Note; prediction: NotePrediction }[];
|
||||
}, [notes, accessLogs]);
|
||||
|
||||
const cyclicalNotes = useMemo(() => {
|
||||
return notes
|
||||
.map(note => {
|
||||
const noteLogs = accessLogs.filter(l => l.noteId === note.id);
|
||||
const cycle = detectAccessCycle(noteLogs);
|
||||
return { note, cycle };
|
||||
})
|
||||
.filter(p => p.cycle !== null) as { note: Note; cycle: number }[];
|
||||
}, [notes, accessLogs]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-paper dark:bg-[#0A0A0A] overflow-hidden">
|
||||
<div className="p-8 border-b border-border/40 backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-rose-500/10 flex items-center justify-center text-rose-500">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Temporal Forecast</h1>
|
||||
</div>
|
||||
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Predicting the recurrence of insight</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
|
||||
<div className="max-w-5xl mx-auto space-y-12">
|
||||
|
||||
{/* Daily Briefing / Predictions */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Sparkles size={16} className="text-rose-400" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Intelligence Briefing</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{predictions.map(({ note, prediction }) => (
|
||||
<motion.div
|
||||
key={note.id}
|
||||
whileHover={{ y: -4 }}
|
||||
onClick={() => onNoteSelect(note.id)}
|
||||
className="p-6 rounded-2xl bg-white dark:bg-white/5 border border-border/60 hover:border-rose-400/40 transition-all cursor-pointer shadow-sm relative overflow-hidden group"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-3">
|
||||
<TrendingUp size={14} className="text-rose-400 opacity-40 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="px-2 py-1 bg-rose-500/10 text-rose-500 text-[10px] font-bold rounded-full uppercase tracking-widest">
|
||||
Coming Up
|
||||
</span>
|
||||
<span className="text-[10px] text-concrete font-medium">
|
||||
{new Date(prediction.predictedRelevanceDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="text-lg font-serif font-medium text-ink dark:text-dark-ink mb-2 group-hover:text-rose-500 transition-colors uppercase tracking-tight">{note.title}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-6 italic">
|
||||
"{prediction.reason}"
|
||||
</p>
|
||||
|
||||
<div className="pt-4 border-t border-border/40 flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-concrete uppercase tracking-widest">Confidence {Math.round(prediction.confidence * 100)}%</span>
|
||||
<ArrowRight size={14} className="text-concrete group-hover:text-ink" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{predictions.length === 0 && (
|
||||
<div className="col-span-full p-12 text-center bg-paper dark:bg-white/5 border-2 border-dashed border-border rounded-3xl">
|
||||
<Calendar size={40} className="mx-auto text-concrete/40 mb-4" />
|
||||
<h5 className="text-ink dark:text-dark-ink font-medium mb-1">No upcoming predictions</h5>
|
||||
<p className="text-xs text-concrete">The system needs more usage data to find cyclical patterns in your research.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cyclical Patterns */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<History size={16} className="text-indigo-400" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Detected Cycles</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{cyclicalNotes.map(({ note, cycle }) => (
|
||||
<div key={note.id} className="flex items-center gap-4 p-4 rounded-xl bg-slate-50 dark:bg-white/5 border border-border/40">
|
||||
<div className="w-12 h-12 rounded-xl bg-white dark:bg-black/20 border border-border flex items-center justify-center flex-col shadow-sm">
|
||||
<span className="text-xs font-bold text-ink dark:text-dark-ink">{Math.round(cycle)}</span>
|
||||
<span className="text-[8px] font-bold text-concrete uppercase">days</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="text-sm font-medium text-ink dark:text-dark-ink">{note.title}</h5>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-concrete">Recurring theme in your architectural process</span>
|
||||
<div className="h-1 w-24 bg-border/40 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-rose-400" style={{ width: '65%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => onNoteSelect(note.id)} className="p-2 hover:bg-black/5 rounded-full">
|
||||
<ArrowRight size={16} className="text-concrete" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Productivity Stats */}
|
||||
<section className="bg-ink dark:bg-white/5 rounded-3xl p-8 text-paper relative overflow-hidden">
|
||||
<div className="relative z-10 grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h6 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-60 mb-2">Memory Strength</h6>
|
||||
<div className="text-4xl font-serif">84%</div>
|
||||
<p className="text-[10px] opacity-60 mt-2">Active connections in your semantic network</p>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-60 mb-2">Peak Cycle</h6>
|
||||
<div className="text-4xl font-serif">28 Days</div>
|
||||
<p className="text-[10px] opacity-60 mt-2">The rhythm of your creative output</p>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-2 h-2 rounded-full bg-rose-400"></div>
|
||||
<span className="text-xs font-medium">4 Notes resurfacing this week</span>
|
||||
</div>
|
||||
<button className="w-full py-2 bg-paper text-ink rounded-xl text-[10px] font-bold uppercase tracking-widest hover:scale-105 transition-transform">
|
||||
Build Morning Briefing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-rose-500/10 blur-[80px] rounded-full -mr-20 -mt-20"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
218
architectural-grid12/src/components/TrashView.tsx
Normal file
218
architectural-grid12/src/components/TrashView.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
X,
|
||||
FileText,
|
||||
Folder,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Note, Carnet } from '../types';
|
||||
|
||||
interface TrashViewProps {
|
||||
deletedNotes: Note[];
|
||||
deletedCarnets: Carnet[];
|
||||
onRestoreNote: (id: string) => void;
|
||||
onRestoreCarnet: (id: string) => void;
|
||||
onPermanentDeleteNote: (id: string) => void;
|
||||
onPermanentDeleteCarnet: (id: string) => void;
|
||||
onEmptyTrash: () => void;
|
||||
}
|
||||
|
||||
export const TrashView: React.FC<TrashViewProps> = ({
|
||||
deletedNotes,
|
||||
deletedCarnets,
|
||||
onRestoreNote,
|
||||
onRestoreCarnet,
|
||||
onPermanentDeleteNote,
|
||||
onPermanentDeleteCarnet,
|
||||
onEmptyTrash
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [filterType, setFilterType] = React.useState<'all' | 'notes' | 'carnets'>('all');
|
||||
|
||||
const getDaysRemaining = (dateString?: string) => {
|
||||
if (!dateString) return 30;
|
||||
const deletedDate = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = now.getTime() - deletedDate.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
return Math.max(0, 30 - diffDays);
|
||||
};
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
const items = [
|
||||
...deletedNotes.map(n => ({ ...n, itemType: 'note' as const })),
|
||||
...deletedCarnets.map(c => ({ ...c, itemType: 'carnet' as const }))
|
||||
];
|
||||
|
||||
return items
|
||||
.filter(item => {
|
||||
const matchesSearch = ('title' in item ? item.title : item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesType = filterType === 'all' || (filterType === 'notes' && item.itemType === 'note') || (filterType === 'carnets' && item.itemType === 'carnet');
|
||||
return matchesSearch && matchesType;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = a.deletedAt ? new Date(a.deletedAt).getTime() : 0;
|
||||
const dateB = b.deletedAt ? new Date(b.deletedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
}, [deletedNotes, deletedCarnets, searchQuery, filterType]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-dark-paper">
|
||||
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-[#F9F8F6]/80 backdrop-blur-md z-30 border-b border-border/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-4xl font-serif font-medium text-ink flex items-center gap-4">
|
||||
Corbeille <Trash2 size={28} className="text-rose-400 opacity-40" />
|
||||
</h1>
|
||||
<p className="text-[10px] text-concrete font-bold uppercase tracking-[0.3em] opacity-60">
|
||||
Auto-suppression après 30 jours
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{filteredItems.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Vider la corbeille ? Cette action est irréversible.')) {
|
||||
onEmptyTrash();
|
||||
}
|
||||
}}
|
||||
className="px-6 py-3 bg-paper border border-border text-rose-500 rounded-2xl text-[10px] font-bold uppercase tracking-widest hover:bg-rose-50 hover:border-rose-100 transition-all shadow-sm"
|
||||
>
|
||||
Vider tout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="group relative flex-1 max-w-xl">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-concrete group-focus-within:text-ink transition-colors" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-white dark:bg-white/5 border border-border/40 rounded-2xl pl-12 pr-6 py-4 text-sm outline-none focus:ring-4 ring-ink/5 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-white dark:bg-white/5 p-1 rounded-2xl border border-border shadow-sm">
|
||||
{(['all', 'notes', 'carnets'] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setFilterType(type)}
|
||||
className={`px-6 py-3 rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all
|
||||
${filterType === type ? 'bg-ink text-paper shadow-lg' : 'text-concrete hover:text-ink'}`}
|
||||
>
|
||||
{type === 'all' ? 'Tous' : type === 'notes' ? 'Notes' : 'Carnets'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-12 py-12 overflow-y-auto custom-scrollbar">
|
||||
{filteredItems.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredItems.map((item) => {
|
||||
const daysLeft = getDaysRemaining(item.deletedAt);
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-white dark:bg-white/5 border border-border/60 rounded-[32px] p-8 group hover:shadow-2xl hover:border-blueprint/20 transition-all relative overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Countdown Progress Bar */}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-slate-100 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(daysLeft / 30) * 100}%` }}
|
||||
className={`h-full ${daysLeft < 5 ? 'bg-rose-500' : 'bg-blueprint'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-3 rounded-2xl ${item.itemType === 'note' ? 'bg-blueprint/10 text-blueprint' : 'bg-concrete/10 text-concrete'}`}>
|
||||
{item.itemType === 'note' ? <FileText size={20} /> : <Folder size={20} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => item.itemType === 'note' ? onRestoreNote(item.id) : onRestoreCarnet(item.id)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-50 text-emerald-600 rounded-xl text-[10px] font-bold uppercase tracking-widest hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
<RotateCcw size={12} /> Restaurer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => item.itemType === 'note' ? onPermanentDeleteNote(item.id) : onPermanentDeleteCarnet(item.id)}
|
||||
className="p-2 hover:bg-rose-50 text-rose-500 rounded-xl transition-colors"
|
||||
title="Supprimer définitivement"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-8 flex-1">
|
||||
<h3 className="text-base font-serif font-medium text-ink leading-tight">
|
||||
{'title' in item ? item.title : item.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded border ${daysLeft < 5 ? 'border-rose-200 text-rose-500 bg-rose-50' : 'border-blueprint/20 text-blueprint bg-blueprint/5'}`}>
|
||||
{daysLeft} JOURS RESTANTS
|
||||
</div>
|
||||
<span className="text-[10px] text-concrete font-medium uppercase tracking-tight flex items-center gap-1">
|
||||
<Clock size={10} /> {('deletedAt' in item && item.deletedAt) ? new Date(item.deletedAt).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.itemType === 'note' && 'content' in item ? (
|
||||
<div className="text-[12px] text-concrete line-clamp-3 leading-relaxed opacity-60 font-light border-t border-border/40 pt-4">
|
||||
{item.content.replace(/[#*`]/g, '')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-border/40 pt-4">
|
||||
<div className="text-[9px] font-bold text-concrete/40 uppercase tracking-widest">
|
||||
Contenu du dossier préservé
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center space-y-6 opacity-40">
|
||||
<div className="p-8 rounded-full bg-slate-100 border-2 border-dashed border-border flex items-center justify-center">
|
||||
<Trash2 size={64} className="text-concrete" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-serif text-ink italic">Corbeille vide</h2>
|
||||
<p className="text-sm text-concrete max-w-xs">
|
||||
Les éléments que vous supprimez apparaîtront ici. Ils seront conservés pendant 30 jours avant suppression définitive.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="px-12 py-6 bg-white/50 border-t border-border flex items-center gap-4">
|
||||
<AlertCircle size={14} className="text-concrete" />
|
||||
<p className="text-[10px] text-concrete font-medium uppercase tracking-widest">
|
||||
Conseil : La restauration d'un carnet restaurera également toutes les notes à l'intérieur.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
152
architectural-grid12/src/components/settings/AITab.tsx
Normal file
152
architectural-grid12/src/components/settings/AITab.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { Sparkles, Edit3, MessageCircle, Languages, Tag, History, FlaskConical } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const AISettingCard = ({ icon, title, description, defaultChecked = false }: any) => (
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-6 flex items-center justify-between group hover:shadow-xl hover:shadow-blueprint/5 transition-all duration-300">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-blueprint/10 rounded-2xl text-blueprint group-hover:bg-blueprint group-hover:text-white group-hover:scale-110 transition-all duration-300 border border-blueprint/20">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-ink">{title}</h4>
|
||||
<p className="text-[10px] text-muted-ink leading-relaxed pr-4 line-clamp-2">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked={defaultChecked} />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-blueprint"></div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AITab: React.FC = () => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-16 pb-20"
|
||||
>
|
||||
<div className="space-y-10">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-muted-ink opacity-60">Configurez vos fonctionnalités IA et préférences</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-sm font-bold text-ink border-b border-border/40 pb-4">Fonctionnalités IA</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<AISettingCard
|
||||
icon={<Edit3 size={18} />}
|
||||
title="Suggestions de titre"
|
||||
description="Suggérer des titres pour les notes sans titre après 50+ mots"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<Sparkles size={18} />}
|
||||
title="IA Note"
|
||||
description="Active le bouton de chat IA et les outils d'amélioration du texte"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<MessageCircle size={18} />}
|
||||
title="💡 J'ai remarqué quelque chose..."
|
||||
description="Aperçu quotidien de vos notes"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<Languages size={18} />}
|
||||
title="Détection de langue"
|
||||
description="Détecte automatiquement la langue de vos notes"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<Tag size={18} />}
|
||||
title="Suggestion des labels"
|
||||
description="Suggère et applique des étiquettes automatiquement à vos notes"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<History size={18} />}
|
||||
title="Historique des notes"
|
||||
description="Active les snapshots de versions et la restauration depuis History"
|
||||
defaultChecked
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6">
|
||||
{/* Fréquence */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
|
||||
<div className="space-y-1.5 text-left text-blueprint">
|
||||
<h4 className="text-sm font-bold">Fréquence</h4>
|
||||
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">Fréquence d'analyse des connexions</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="freq" className="sr-only peer" defaultChecked />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">Quotidienne</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="freq" className="sr-only peer" />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">Hebdomadaire</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode d'historique */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
|
||||
<div className="space-y-1.5 text-left text-blueprint">
|
||||
<h4 className="text-sm font-bold">Mode d'historique</h4>
|
||||
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">Gestion des snapshots</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="hist" className="sr-only peer" defaultChecked />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-bold text-ink">Manuel (bouton commit)</p>
|
||||
<p className="text-[10px] text-muted-ink">Créer des snapshots manuellement</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="hist" className="sr-only peer" />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-bold text-ink">Automatique (intelligent)</p>
|
||||
<p className="text-[10px] text-muted-ink">Snapshots automatiques avec détection</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Démo */}
|
||||
<div className="bg-ochre/5 dark:bg-ochre/10 border border-ochre/20 rounded-2xl p-8 flex items-center justify-between group transition-all duration-300 hover:bg-ochre/10">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="p-3 bg-paper dark:bg-ochre/20 rounded-2xl text-ochre border border-ochre/30">
|
||||
<FlaskConical size={20} />
|
||||
</div>
|
||||
<div className="space-y-1.5 text-left">
|
||||
<h4 className="text-sm font-bold text-ink flex items-center gap-3">
|
||||
🧪 Mode Démo
|
||||
</h4>
|
||||
<p className="text-[11px] text-muted-ink leading-relaxed font-medium">Accélère Memory Echo pour les tests. Les connexions apparaissent instantanément.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
|
||||
<input type="checkbox" className="sr-only peer" />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ochre"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const AppearanceSelect = ({ icon, title, description, options, defaultValue }: any) => (
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-slate/5">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border group-hover:scale-110 transition-transform duration-300">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-0.5 text-left">
|
||||
<h4 className="text-base font-bold text-ink">{title}</h4>
|
||||
<p className="text-[11px] text-concrete leading-tight">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative group/select">
|
||||
<select
|
||||
defaultValue={defaultValue}
|
||||
className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-4 text-sm outline-none focus:ring-1 ring-slate/20 appearance-none cursor-pointer text-ink font-bold transition-all hover:bg-white dark:hover:bg-black/60"
|
||||
>
|
||||
{options.map((opt: string) => (
|
||||
<option key={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-concrete group-hover/select:text-slate transition-colors">
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AppearanceTab: React.FC = () => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-16 pb-20"
|
||||
>
|
||||
<div className="space-y-10">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">Personnaliser l'apparence de l'application</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<AppearanceSelect
|
||||
icon={<Palette size={20} />}
|
||||
title="Thème"
|
||||
description="Sélectionner le mode visuel"
|
||||
options={['Clair', 'Sombre', 'Système']}
|
||||
defaultValue="Clair"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<Type size={20} />}
|
||||
title="Taille de la police"
|
||||
description="Ajustez la lisibilité globale de l'interface"
|
||||
options={['Petite', 'Moyenne', 'Grande']}
|
||||
defaultValue="Moyenne"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<Type size={20} />}
|
||||
title="Famille de polices"
|
||||
description="La typographie définit l'âme de l'application"
|
||||
options={['Inter', 'JetBrains Mono', 'Public Sans', 'Outfit']}
|
||||
defaultValue="JetBrains Mono"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<LayoutGrid size={20} />}
|
||||
title="Affichage des notes"
|
||||
description="Gestion visuelle de la grille de composition"
|
||||
options={['Cartes (grille)', 'Liste', 'Tableau']}
|
||||
defaultValue="Cartes (grille)"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<Maximize size={20} />}
|
||||
title="Taille des notes"
|
||||
description="Structure de la mise en page des éléments"
|
||||
options={['Taille uniforme', 'Variable (Masonry)']}
|
||||
defaultValue="Taille uniforme"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
82
architectural-grid12/src/components/settings/GeneralTab.tsx
Normal file
82
architectural-grid12/src/components/settings/GeneralTab.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { Globe, Bell } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
export const GeneralTab: React.FC = () => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-12"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">Paramètres généraux de l'application</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Langue */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border">
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-base font-bold text-ink">Langue</h4>
|
||||
<p className="text-[11px] text-concrete">Sélectionner une langue</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<select className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-3.5 text-sm outline-none focus:ring-1 ring-blueprint/20 appearance-none cursor-pointer transition-all hover:bg-white dark:hover:bg-black/60 text-ink font-medium">
|
||||
<option>Français</option>
|
||||
<option>English</option>
|
||||
<option>Español</option>
|
||||
</select>
|
||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none opacity-40 text-concrete">
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border">
|
||||
<Bell size={18} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-base font-bold text-ink">Notifications</h4>
|
||||
<p className="text-[11px] text-concrete">Gérez vos préférences de notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 divide-y divide-border/40 text-left">
|
||||
<div className="flex items-center justify-between pt-0">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">Notifications par email</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">Recevoir des notifications importantes par email</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-slate"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">Notifications bureau</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">Recevoir des notifications dans votre navigateur</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-slate"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Settings, Sparkles, Palette, User, Database, Code, Info } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { SettingsTab } from '../../types';
|
||||
|
||||
interface SettingsHeaderProps {
|
||||
activeTab: SettingsTab;
|
||||
setActiveTab: (tab: SettingsTab) => void;
|
||||
}
|
||||
|
||||
export const SettingsHeader: React.FC<SettingsHeaderProps> = ({ activeTab, setActiveTab }) => {
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'Paramètres généraux', icon: <Settings size={14} /> },
|
||||
{ id: 'ai', label: 'Paramètres IA', icon: <Sparkles size={14} /> },
|
||||
{ id: 'appearance', label: 'Apparence', icon: <Palette size={14} /> },
|
||||
{ id: 'profile', label: 'Profil', icon: <User size={14} /> },
|
||||
{ id: 'data', label: 'Gestion des données', icon: <Database size={14} /> },
|
||||
{ id: 'mcp', label: 'Paramètres MCP', icon: <Code size={14} /> },
|
||||
{ id: 'about', label: 'À propos', icon: <Info size={14} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="px-12 pt-20 pb-16 space-y-12">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">Paramètres</h1>
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">Configuration & Préférences</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-1 border-b border-border/40 pb-px">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as SettingsTab)}
|
||||
className={`flex items-center gap-2.5 px-6 py-5 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap
|
||||
${activeTab === tab.id ? 'text-ink' : 'text-concrete hover:text-ink/60'}`}
|
||||
>
|
||||
<span className={activeTab === tab.id ? 'text-ink' : 'text-concrete'}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<motion.div
|
||||
layoutId="activeSettingsTabLine"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink"
|
||||
transition={{ type: 'spring', bounce: 0.1, duration: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
93
architectural-grid12/src/constants.ts
Normal file
93
architectural-grid12/src/constants.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Carnet, Note } from './types';
|
||||
|
||||
export 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' },
|
||||
{ id: '5', name: 'History of Architecture', initial: 'H', type: 'Project', parentId: '4' },
|
||||
{ id: '6', name: 'Modernism', initial: 'M', type: 'Project', parentId: '5' },
|
||||
{ id: '7', name: 'Sustainable Design', initial: 'S', type: 'Project', parentId: '4' },
|
||||
];
|
||||
|
||||
export const ALL_NOTES: Note[] = [
|
||||
{
|
||||
id: 'n1',
|
||||
carnetId: '4',
|
||||
title: 'Grid Systems & Geometry',
|
||||
date: 'Oct 26, 2024',
|
||||
content: 'Grid Systems are the foundation of cognitive design. We use geometric blocks to define spaces. The repetitive structure creates a sense of order and rhythm in the built environment.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [
|
||||
{ id: 't1', label: 'Architecture', type: 'user' },
|
||||
{ id: 't2', label: 'Systems', type: 'ai' }
|
||||
],
|
||||
embedding: [0.1, 0.1]
|
||||
},
|
||||
{
|
||||
id: 'n1-b',
|
||||
carnetId: '4',
|
||||
title: 'Parametric Grids',
|
||||
date: 'Oct 27, 2024',
|
||||
content: 'Parametricism allows us to deform traditional grid systems. By using mathematical algorithms, we can create fluid yet structured geometries that respond to environmental data.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1511225070737-5af5ac9a690d?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't1', label: 'Geometry', type: 'user' }],
|
||||
embedding: [0.12, 0.08]
|
||||
},
|
||||
{
|
||||
id: 'n2',
|
||||
carnetId: '4',
|
||||
title: 'Sustainable Materiality',
|
||||
date: 'Oct 24, 2024',
|
||||
content: 'Exploring cross-laminated timber (CLT) as a sustainable alternative to concrete. Material choice is key to carbon-neutral construction. The warmth of wood contrasts with the coldness of steel.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [
|
||||
{ id: 't3', label: 'Materials', type: 'user' },
|
||||
{ id: 't4', label: 'Sustainabilty', type: 'ai' }
|
||||
],
|
||||
embedding: [0.8, 0.8]
|
||||
},
|
||||
{
|
||||
id: 'n2-b',
|
||||
carnetId: '7',
|
||||
title: 'Solar Passive Design',
|
||||
date: 'Oct 25, 2024',
|
||||
content: 'Using orientation to maximize natural heat. Sustainable architecture must prioritize passive systems over active ones. Thermal mass and insulation are critical factors.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1509391366360-fe5bb5843e0c?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't4', label: 'Sustainabilty', type: 'user' }],
|
||||
embedding: [0.85, 0.75]
|
||||
},
|
||||
{
|
||||
id: 'n3',
|
||||
carnetId: '4',
|
||||
title: 'Light & Minimalist Space',
|
||||
date: 'Oct 22, 2024',
|
||||
content: 'Minimalism is about the subtraction of the unnecessary. Light becomes a material in itself. Reflections on glass and white surfaces create depth without clutter.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [
|
||||
{ id: 't5', label: 'Lighting', type: 'user' },
|
||||
{ id: 't6', label: 'Atmosphere', type: 'ai' }
|
||||
],
|
||||
embedding: [0.2, 0.8]
|
||||
},
|
||||
{
|
||||
id: 'n3-b',
|
||||
carnetId: '6',
|
||||
title: 'The Glass House Study',
|
||||
date: 'Oct 23, 2024',
|
||||
content: 'Analyzing the transparency of the Glass House. The boundary between interior and exterior is blurred. A pure expression of modernist ideals and minimal structure.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1464938050520-ef2270bb8ce8?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't6', label: 'Modernism', type: 'user' }],
|
||||
embedding: [0.25, 0.85]
|
||||
},
|
||||
{
|
||||
id: 'bridge-1',
|
||||
carnetId: '4',
|
||||
title: 'Geometric Ecology',
|
||||
date: 'Oct 28, 2024',
|
||||
content: 'Can we use grid systems to optimize sustainable solar collection? This note bridges the gap between rigid geometry and ecological necessity. Structured sustainability.',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1464146072230-91cabc968276?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't1', label: 'Bridge', type: 'ai' }],
|
||||
embedding: [0.45, 0.45] // Center point
|
||||
}
|
||||
];
|
||||
98
architectural-grid12/src/index.css
Normal file
98
architectural-grid12/src/index.css
Normal file
@@ -0,0 +1,98 @@
|
||||
@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;
|
||||
|
||||
/* Foundation */
|
||||
--color-paper: #F2F0E9;
|
||||
--color-ink: #1C1C1C;
|
||||
--color-muted-ink: rgba(28, 28, 28, 0.6);
|
||||
--color-border: rgba(28, 28, 28, 0.1);
|
||||
--color-concrete: #8D8D8D;
|
||||
|
||||
/* Architectural Accents */
|
||||
--color-blueprint: #75B2D6;
|
||||
--color-slate: #4A4E69;
|
||||
--color-ochre: #D4A373;
|
||||
--color-sage: #A3B18A;
|
||||
--color-rust: #9B2226;
|
||||
--color-glass: rgba(255, 255, 255, 0.4);
|
||||
|
||||
/* Dark Theme Aliases */
|
||||
--color-dark-paper: #0D0D0D;
|
||||
--color-dark-ink: #EAEAEA;
|
||||
--color-dark-muted: rgba(234, 234, 234, 0.5);
|
||||
--color-dark-border: rgba(234, 234, 234, 0.1);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-paper text-ink font-sans antialiased transition-colors duration-500;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
@apply bg-dark-paper;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-paper: #121212;
|
||||
--color-ink: #EAEAEA;
|
||||
--color-muted-ink: rgba(234, 234, 234, 0.6);
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-glass: rgba(0, 0, 0, 0.4);
|
||||
--color-concrete: #555555;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.dark .ai-glass {
|
||||
background: rgba(30, 30, 30, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.dark .active-nav-item {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
10
architectural-grid12/src/main.tsx
Normal file
10
architectural-grid12/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>,
|
||||
);
|
||||
228
architectural-grid12/src/services/clusteringService.ts
Normal file
228
architectural-grid12/src/services/clusteringService.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
|
||||
import { Note, NoteCluster, BridgeNote } from '../types';
|
||||
import { cosineSimilarity } from './geminiService';
|
||||
|
||||
export function dbscan(notes: Note[], eps: number, minPts: number): number[] {
|
||||
const n = notes.length;
|
||||
const labels = new Array(n).fill(-1); // -1 = noise, 0+ = cluster id
|
||||
let clusterId = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (labels[i] !== -1) continue;
|
||||
|
||||
const neighbors = getNeighbors(i, notes, eps);
|
||||
|
||||
if (neighbors.length < minPts) {
|
||||
labels[i] = -1; // remains noise for now
|
||||
continue;
|
||||
}
|
||||
|
||||
labels[i] = clusterId;
|
||||
const queue = neighbors.filter(idx => idx !== i);
|
||||
|
||||
for (let j = 0; j < queue.length; j++) {
|
||||
const pIdx = queue[j];
|
||||
|
||||
if (labels[pIdx] === -1) {
|
||||
labels[pIdx] = clusterId; // noisy point becomes border point
|
||||
}
|
||||
|
||||
if (labels[pIdx] !== -1 && labels[pIdx] < clusterId) {
|
||||
// This should not happen in standard DBSCAN unless we re-visit
|
||||
}
|
||||
|
||||
if (labels[pIdx] === clusterId && labels[pIdx] !== -1) {
|
||||
// Skip if already processed in this cluster
|
||||
}
|
||||
|
||||
// If it was already labeled, skip re-neighboring
|
||||
const pWasNoise = labels[pIdx] === -1;
|
||||
if (labels[pIdx] === -1) labels[pIdx] = clusterId;
|
||||
|
||||
// If point was not processed
|
||||
if (pWasNoise || labels[pIdx] === clusterId ) {
|
||||
// This is a simplified queue processing
|
||||
}
|
||||
}
|
||||
|
||||
// Standard DBSCAN expansion
|
||||
expandCluster(i, neighbors, labels, clusterId, notes, eps, minPts);
|
||||
clusterId++;
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
function expandCluster(pIdx: number, neighbors: number[], labels: number[], clusterId: number, notes: Note[], eps: number, minPts: number) {
|
||||
let i = 0;
|
||||
while (i < neighbors.length) {
|
||||
const qIdx = neighbors[i];
|
||||
if (labels[qIdx] === -1) {
|
||||
labels[qIdx] = clusterId;
|
||||
} else if (labels[qIdx] === undefined || labels[qIdx] === -1) {
|
||||
// unreachable
|
||||
}
|
||||
|
||||
if (labels[qIdx] === clusterId || labels[qIdx] === -1) {
|
||||
const qNeighbors = getNeighbors(qIdx, notes, eps);
|
||||
if (qNeighbors.length >= minPts) {
|
||||
for(const qn of qNeighbors) {
|
||||
if (labels[qn] === -1) {
|
||||
labels[qn] = clusterId;
|
||||
neighbors.push(qn);
|
||||
} else if (!labels.hasOwnProperty(qn)) {
|
||||
// logic error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean DBSCAN implementation
|
||||
export function runClustering(notes: Note[], eps: number = 0.15, minPts: number = 2): { labels: number[], clusters: NoteCluster[] } {
|
||||
const validNotes = notes.filter(n => n.embedding && n.embedding.length > 0);
|
||||
if (validNotes.length === 0) return { labels: [], clusters: [] };
|
||||
|
||||
const n = validNotes.length;
|
||||
const labels = new Array(n).fill(-1);
|
||||
let cId = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (labels[i] !== -1) continue;
|
||||
|
||||
const neighbors = findNeighbors(i, validNotes, eps);
|
||||
if (neighbors.length < minPts) {
|
||||
labels[i] = -1;
|
||||
} else {
|
||||
labels[i] = cId;
|
||||
expand(i, neighbors, labels, cId, validNotes, eps, minPts);
|
||||
cId++;
|
||||
}
|
||||
}
|
||||
|
||||
const clusters: NoteCluster[] = [];
|
||||
const colorPalette = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF'];
|
||||
|
||||
for (let i = 0; i < cId; i++) {
|
||||
const noteIds = validNotes.filter((_, idx) => labels[idx] === i).map(n => n.id);
|
||||
clusters.push({
|
||||
id: `cluster-${i}`,
|
||||
name: `Cluster ${i + 1}`,
|
||||
noteIds,
|
||||
color: colorPalette[i % colorPalette.length]
|
||||
});
|
||||
}
|
||||
|
||||
return { labels, clusters };
|
||||
}
|
||||
|
||||
function findNeighbors(idx: number, notes: Note[], eps: number): number[] {
|
||||
const neighbors: number[] = [];
|
||||
const targetEmbedding = notes[idx].embedding!;
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
const sim = cosineSimilarity(targetEmbedding, notes[i].embedding!);
|
||||
const dist = 1 - sim;
|
||||
if (dist <= eps) {
|
||||
neighbors.push(i);
|
||||
}
|
||||
}
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
function expand(rootIdx: number, neighbors: number[], labels: number[], cId: number, notes: Note[], eps: number, minPts: number) {
|
||||
const queue = [...neighbors];
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const qIdx = queue[i];
|
||||
if (labels[qIdx] === -1) {
|
||||
labels[qIdx] = cId;
|
||||
}
|
||||
if (labels[qIdx] !== -1 && labels[qIdx] !== cId) continue;
|
||||
if (labels[qIdx] === cId) {
|
||||
// already visited but let's check neighbors if we just added it
|
||||
}
|
||||
|
||||
// If point was noise, it now belongs to cluster, but we don't necessarily expand from it unless it's a core point
|
||||
// This is the standard DBSCAN: noise points can become border points
|
||||
}
|
||||
|
||||
// Re-implementing correctly
|
||||
let head = 0;
|
||||
while(head < queue.length) {
|
||||
const qIdx = queue[head];
|
||||
if (labels[qIdx] === -1) labels[qIdx] = cId;
|
||||
if (labels[qIdx] === cId) {
|
||||
const qNeighbors = findNeighbors(qIdx, notes, eps);
|
||||
if (qNeighbors.length >= minPts) {
|
||||
for(const qn of qNeighbors) {
|
||||
if (labels[qn] === -1) {
|
||||
labels[qn] = cId;
|
||||
queue.push(qn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
head++;
|
||||
}
|
||||
}
|
||||
|
||||
function getNeighbors(idx: number, notes: Note[], eps: number): number[] {
|
||||
const neighbors: number[] = [];
|
||||
const target = notes[idx].embedding!;
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
if (!notes[i].embedding) continue;
|
||||
const dist = 1 - cosineSimilarity(target, notes[i].embedding!);
|
||||
if (dist <= eps) neighbors.push(i);
|
||||
}
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
export function detectBridges(notes: Note[], clusters: NoteCluster[], threshold: number = 0.5): BridgeNote[] {
|
||||
const bridges: BridgeNote[] = [];
|
||||
const validNotes = notes.filter(n => n.embedding);
|
||||
|
||||
for (const note of validNotes) {
|
||||
const connectedClusters = new Set<string>();
|
||||
|
||||
for (const cluster of clusters) {
|
||||
// Check if note has strong links to ANY note in this cluster
|
||||
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id) && n.embedding);
|
||||
const hasStrongLink = clusterNotes.some(cn => cosineSimilarity(note.embedding!, cn.embedding!) > threshold);
|
||||
|
||||
if (hasStrongLink) {
|
||||
connectedClusters.add(cluster.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedClusters.size >= 2) {
|
||||
bridges.push({
|
||||
noteId: note.id,
|
||||
connectedClusterIds: Array.from(connectedClusters),
|
||||
bridgeScore: connectedClusters.size / Math.max(clusters.length, 1)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return bridges.sort((a, b) => b.bridgeScore - a.bridgeScore);
|
||||
}
|
||||
|
||||
export function calculateCentroid(noteIds: string[], allNotes: Note[]): number[] | undefined {
|
||||
const clusterNotes = allNotes.filter(n => noteIds.includes(n.id) && n.embedding);
|
||||
if (clusterNotes.length === 0) return undefined;
|
||||
|
||||
const embeddingDim = clusterNotes[0].embedding!.length;
|
||||
const centroid = new Array(embeddingDim).fill(0);
|
||||
|
||||
for (const note of clusterNotes) {
|
||||
for (let i = 0; i < embeddingDim; i++) {
|
||||
centroid[i] += note.embedding![i];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < embeddingDim; i++) {
|
||||
centroid[i] /= clusterNotes.length;
|
||||
}
|
||||
|
||||
return centroid;
|
||||
}
|
||||
200
architectural-grid12/src/services/geminiService.ts
Normal file
200
architectural-grid12/src/services/geminiService.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { BrainstormIdea } from "../types";
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
|
||||
const BRAINSTORM_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
ideas: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
title: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
connection_to_seed: { type: Type.STRING },
|
||||
novelty_score: { type: Type.NUMBER }
|
||||
},
|
||||
required: ["title", "description", "connection_to_seed", "novelty_score"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["ideas"]
|
||||
};
|
||||
|
||||
const SUGGESTIONS_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
suggestions: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
title: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
reasoning: { type: Type.STRING }
|
||||
},
|
||||
required: ["title", "description", "reasoning"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["suggestions"]
|
||||
};
|
||||
|
||||
export async function generateBrainstormWave(
|
||||
seedIdea: string,
|
||||
waveNumber: number,
|
||||
contextSummaries: string = ""
|
||||
): Promise<Partial<BrainstormIdea>[]> {
|
||||
const waveDescriptions = [
|
||||
"", // index 0 unused
|
||||
"VAGUE 1 (proximité directe) : Sous-aspects, reformulations, variations de l'idée. Reste dans le même domaine.",
|
||||
"VAGUE 2 (analogies) : Trouve des parallèles dans d'autres domaines. Comment cette idée se manifeste-t-elle ailleurs ? Quelles techniques d'autres industries pourraient s'appliquer ?",
|
||||
"VAGUE 3 (disruption) : Inverse l'idée. Pousse-la à l'extrême. Combine-la avec un domaine totalement non lié. Que se passe-t-il si l'opposé est vrai ?"
|
||||
];
|
||||
|
||||
const prompt = `
|
||||
Idée seed : "${seedIdea}"
|
||||
Contexte : ${contextSummaries}
|
||||
Génère 5 idées pour la VAGUE ${waveNumber} : ${waveDescriptions[waveNumber]}
|
||||
Format JSON selon le schéma.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
systemInstruction: "Tu es un expert en brainstorming. Réponds uniquement en JSON valide.",
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: BRAINSTORM_SCHEMA,
|
||||
temperature: 1.0
|
||||
}
|
||||
});
|
||||
|
||||
const resText = response.text;
|
||||
if (!resText) return [];
|
||||
|
||||
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
|
||||
const ideas = Array.isArray(parsed.ideas) ? parsed.ideas : (Array.isArray(parsed) ? parsed : []);
|
||||
|
||||
return ideas.map((item: any) => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
connectionToSeed: item.connection_to_seed,
|
||||
noveltyScore: item.novelty_score,
|
||||
waveNumber
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Error generating brainstorm wave ${waveNumber}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateExpansion(parentIdeaTitle: string, parentIdeaDescription: string): Promise<Partial<BrainstormIdea>[]> {
|
||||
const prompt = `
|
||||
Idée source : "${parentIdeaTitle} - ${parentIdeaDescription}"
|
||||
Génère 3 idées d'extension ou de sous-aspects.
|
||||
Format JSON.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
systemInstruction: "Tu es un expert en brainstorming. Réponds uniquement en JSON valide.",
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: BRAINSTORM_SCHEMA,
|
||||
temperature: 1.0
|
||||
}
|
||||
});
|
||||
|
||||
const resText = response.text;
|
||||
if (!resText) return [];
|
||||
|
||||
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
|
||||
const ideas = Array.isArray(parsed.ideas) ? parsed.ideas : (Array.isArray(parsed) ? parsed : []);
|
||||
|
||||
return ideas.map((item: any) => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
connectionToSeed: item.connection_to_seed,
|
||||
noveltyScore: item.novelty_score
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error generating expansion:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
const result = await ai.models.embedContent({
|
||||
model: 'gemini-embedding-2-preview',
|
||||
contents: [text],
|
||||
});
|
||||
return result.embeddings[0].values;
|
||||
} catch (error) {
|
||||
console.error("Error generating embedding:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (!a || !b || a.length !== b.length) return 0;
|
||||
const dotProduct = a.reduce((sum, val, i) => sum + val * b[i], 0);
|
||||
const magnitudeA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
|
||||
const magnitudeB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
|
||||
if (magnitudeA === 0 || magnitudeB === 0) return 0;
|
||||
return dotProduct / (magnitudeA * magnitudeB);
|
||||
}
|
||||
|
||||
export async function nameCluster(noteSummaries: string[]): Promise<string> {
|
||||
const prompt = `Quel thème commun relie ces notes ? Donne un nom court (2-4 mots).\nNotes :\n${noteSummaries.join('\n- ')}`;
|
||||
try {
|
||||
const result = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt
|
||||
});
|
||||
return result.text.trim();
|
||||
} catch (error) {
|
||||
console.error("Error naming cluster:", error);
|
||||
return "Thematic Cluster";
|
||||
}
|
||||
}
|
||||
|
||||
export async function suggestBridgeIdeas(
|
||||
clusterAName: string,
|
||||
clusterBName: string,
|
||||
clusterASummaries: string,
|
||||
clusterBSummaries: string
|
||||
): Promise<any[]> {
|
||||
const prompt = `
|
||||
Cluster A (${clusterAName}) contient des notes sur : ${clusterASummaries}
|
||||
Cluster B (${clusterBName}) contient des notes sur : ${clusterBSummaries}
|
||||
|
||||
Ces deux clusters ne sont pas connectés. Propose 3 idées
|
||||
de "notes pont" qui pourraient créer un lien créatif entre eux.
|
||||
Pour chaque idée : titre, description, pourquoi ça connecte les deux.
|
||||
|
||||
Format JSON.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt,
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: SUGGESTIONS_SCHEMA
|
||||
}
|
||||
});
|
||||
return JSON.parse(response.text);
|
||||
} catch (error) {
|
||||
console.error("Error suggesting bridge ideas:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
76
architectural-grid12/src/services/temporalService.ts
Normal file
76
architectural-grid12/src/services/temporalService.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Note, NoteAccessLog, NotePrediction } from '../types';
|
||||
|
||||
/**
|
||||
* Simulates finding the dominant frequency in access logs for a specific note
|
||||
* returning the period in days.
|
||||
*/
|
||||
export function detectAccessCycle(logs: NoteAccessLog[]): number | null {
|
||||
if (logs.length < 5) return null;
|
||||
|
||||
const accessDays = logs
|
||||
.map(log => new Date(log.accessedAt).getTime())
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const intervals: number[] = [];
|
||||
for (let i = 1; i < accessDays.length; i++) {
|
||||
intervals.push((accessDays[i] - accessDays[i - 1]) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// Simple heuristic: if intervals are consistently around a value, that's our cycle
|
||||
// We'll calculate the median interval
|
||||
const sortedIntervals = [...intervals].sort((a, b) => a - b);
|
||||
const median = sortedIntervals[Math.floor(sortedIntervals.length / 2)];
|
||||
|
||||
// Check if enough intervals are close to median
|
||||
const withinThreshold = intervals.filter(v => Math.abs(v - median) < Math.max(2, median * 0.2));
|
||||
|
||||
if (withinThreshold.length >= intervals.length * 0.6) {
|
||||
return median;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function predictNextAccess(note: Note, logs: NoteAccessLog[]): NotePrediction | null {
|
||||
const cycleDays = detectAccessCycle(logs);
|
||||
if (!cycleDays) return null;
|
||||
|
||||
const lastAccess = new Date(logs[logs.length - 1].accessedAt);
|
||||
const nextAccessDate = new Date(lastAccess.getTime() + cycleDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const now = new Date();
|
||||
const daysUntilNext = (nextAccessDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||
|
||||
// Only predict if it's coming up in the next 2 weeks
|
||||
if (daysUntilNext > 0 && daysUntilNext < 14) {
|
||||
return {
|
||||
noteId: note.id,
|
||||
predictedRelevanceDate: nextAccessDate.toISOString(),
|
||||
confidence: 0.7,
|
||||
reason: `Historical access pattern suggests a ${Math.round(cycleDays)}-day cycle.`,
|
||||
generatedAt: now.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getCoaccessedNotes(baseNoteId: string, logs: NoteAccessLog[], allNotes: Note[]): Note[] {
|
||||
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
const baseNoteLogs = logs.filter(l => l.noteId === baseNoteId);
|
||||
const coaccessedIds = new Set<string>();
|
||||
|
||||
baseNoteLogs.forEach(baseLog => {
|
||||
const baseTime = new Date(baseLog.accessedAt).getTime();
|
||||
logs.forEach(otherLog => {
|
||||
if (otherLog.noteId === baseNoteId) return;
|
||||
const otherTime = new Date(otherLog.accessedAt).getTime();
|
||||
if (Math.abs(baseTime - otherTime) < WINDOW_MS) {
|
||||
coaccessedIds.add(otherLog.noteId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return allNotes.filter(n => coaccessedIds.has(n.id));
|
||||
}
|
||||
102
architectural-grid12/src/types.ts
Normal file
102
architectural-grid12/src/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export type NavigationView = 'notebooks' | 'agents' | 'settings' | 'shared' | 'reminders' | 'trash' | 'brainstorm' | 'insights' | 'temporal';
|
||||
export type AITone = 'Professional' | 'Creative' | 'Academic' | 'Casual';
|
||||
export type AITab = 'discussion' | 'actions' | 'resources' | 'explore';
|
||||
export type SettingsTab = 'general' | 'ai' | 'appearance' | 'profile' | 'data' | 'mcp' | 'about';
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'ai' | 'user';
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
carnetId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
imageUrl: string;
|
||||
date: string;
|
||||
tags: Tag[];
|
||||
isPinned?: boolean;
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: string;
|
||||
embedding?: number[];
|
||||
clusterId?: string;
|
||||
}
|
||||
|
||||
export interface NoteCluster {
|
||||
id: string;
|
||||
name: string;
|
||||
noteIds: string[];
|
||||
centroid? : number[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface BridgeNote {
|
||||
noteId: string;
|
||||
connectedClusterIds: string[];
|
||||
bridgeScore: number;
|
||||
}
|
||||
|
||||
export interface ConnectionSuggestion {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reasoning: string;
|
||||
clusterAId: string;
|
||||
clusterBId: string;
|
||||
}
|
||||
|
||||
export interface BrainstormSession {
|
||||
id: string;
|
||||
seedIdea: string;
|
||||
sourceNoteId?: string;
|
||||
contextNoteIds?: string[];
|
||||
exportedNoteId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type BrainstormIdeaStatus = 'active' | 'dismissed' | 'converted';
|
||||
|
||||
export interface BrainstormIdea {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
waveNumber: 1 | 2 | 3;
|
||||
title: string;
|
||||
description: string;
|
||||
connectionToSeed: string;
|
||||
noveltyScore: number; // 1-10
|
||||
parentIdeaId?: string;
|
||||
convertedToNoteId?: string;
|
||||
relatedNoteIds?: string[];
|
||||
status: BrainstormIdeaStatus;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface Carnet {
|
||||
id: string;
|
||||
name: string;
|
||||
initial: string;
|
||||
type: 'Private' | 'Project' | 'Shared';
|
||||
isPrivate?: boolean;
|
||||
parentId?: string;
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
export interface NoteAccessLog {
|
||||
noteId: string;
|
||||
accessedAt: string;
|
||||
action: 'view' | 'edit' | 'search_hit';
|
||||
}
|
||||
|
||||
export interface NotePrediction {
|
||||
noteId: string;
|
||||
predictedRelevanceDate: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
26
architectural-grid12/tsconfig.json
Normal file
26
architectural-grid12/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-grid12/vite.config.ts
Normal file
24
architectural-grid12/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-grid14/.env.example
Normal file
9
architectural-grid14/.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-grid14/.gitignore
vendored
Normal file
8
architectural-grid14/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
24
architectural-grid14/BRAINSTORM_PROMPT.md
Normal file
24
architectural-grid14/BRAINSTORM_PROMPT.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# IA Agent Coordination Prompt: Brainstorm Wave Integration
|
||||
|
||||
## Context
|
||||
You are tasked with continuing the development of the "Architectural Grid" application. The core feature "Wave Brainstorming" has been partially implemented with a full-stack architecture (Express + React).
|
||||
|
||||
## Current State
|
||||
- **Backend (`server.ts`)**: Implements session management, idea generation via Gemini, and expansion logic. Stores data in memory.
|
||||
- **Frontend (`BrainstormView.tsx`)**: Manages the life cycle of a brainstorm. Integrates with a Radial D3 Canvas.
|
||||
- **Visuals (`WaveCanvas.tsx`)**: Implements a radial force-directed graph with state-aware styling (dismissed/converted).
|
||||
- **Navigation**: "Brainstorm Wave" is accessible from the Sidebar. A quick entry point exists from Note Detail view.
|
||||
|
||||
## Your Task: Sidebar & Navigation Cleanup
|
||||
1. **Source Code Review**: Read `src/components/Sidebar.tsx`, `src/App.tsx`, and `server.ts` to understand how views are toggled.
|
||||
2. **Sidebar Links**: Ensure "Brainstorm Wave", "Semantic Network", and "Temporal Forecast" are correctly grouped and labeled in the Sidebar under a "Creative & AI" section.
|
||||
3. **Agent View Sidebar**: The user specifically requested these links to be also accessible from the "Sidebar of the Agent view". Review `src/components/AgentsView.tsx` and ensure it has consistent navigation or deep links to these advanced features.
|
||||
4. **Semantic Network & Temporal Forecast**: These views are currently placeholders. Ensure the routing and sidebar active state detection work correctly for them.
|
||||
|
||||
## Technical Requirements
|
||||
- Maintain consistency with the **Tailwind** architectural design (concrete, paper, blueprint tokens).
|
||||
- Use **Lucide-React** icons (`Wind` for Brainstorm, `Share2` for Semantic Network, `Clock` for Temporal).
|
||||
- Ensure transitions between views are smooth using `motion/react`.
|
||||
|
||||
---
|
||||
*Copy and paste this into the next AI Agent session to ensure full context transfer.*
|
||||
20
architectural-grid14/README.md
Normal file
20
architectural-grid14/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-grid14/index.html
Normal file
13
architectural-grid14/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-grid14/metadata.json
Normal file
6
architectural-grid14/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Architectural Grid",
|
||||
"description": "A minimalist notebook for architectural research and conceptual sketches, featuring a Wave Brainstorming radial canvas for AI-powered idea exploration.",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": []
|
||||
}
|
||||
5508
architectural-grid14/package-lock.json
generated
Normal file
5508
architectural-grid14/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
architectural-grid14/package.json
Normal file
41
architectural-grid14/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx server.ts",
|
||||
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
|
||||
"start": "node dist/server.cjs",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.29.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"d3": "^7.9.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"esbuild": "^0.28.0",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"uuid": "^14.0.0",
|
||||
"vite": "^6.2.3",
|
||||
"ws": "^8.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
193
architectural-grid14/server.ts
Normal file
193
architectural-grid14/server.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { createServer as createViteServer } from "vite";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { createServer } from "http";
|
||||
|
||||
interface BrainstormIdea {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
waveNumber: number;
|
||||
title: string;
|
||||
description: string;
|
||||
connectionToSeed: string;
|
||||
noveltyScore: number;
|
||||
parentIdeaId?: string;
|
||||
convertedToNoteId?: string;
|
||||
status: 'active' | 'dismissed' | 'converted';
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface BrainstormSession {
|
||||
id: string;
|
||||
seedIdea: string;
|
||||
sourceNoteId?: string;
|
||||
contextNoteIds?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// In-memory store
|
||||
const sessions: BrainstormSession[] = [];
|
||||
const ideas: BrainstormIdea[] = [];
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
const PORT = 3000;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// WebSocket logic
|
||||
const rooms = new Map<string, Set<WebSocket>>();
|
||||
const roomUsers = new Map<string, Map<WebSocket, any>>();
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
let currentRoom: string | null = null;
|
||||
|
||||
ws.on('message', (message) => {
|
||||
const data = JSON.parse(message.toString());
|
||||
|
||||
if (data.type === 'join') {
|
||||
const { sessionId, user } = data;
|
||||
currentRoom = sessionId;
|
||||
|
||||
if (!rooms.has(sessionId)) rooms.set(sessionId, new Set());
|
||||
if (!roomUsers.has(sessionId)) roomUsers.set(sessionId, new Map());
|
||||
|
||||
rooms.get(sessionId)!.add(ws);
|
||||
roomUsers.get(sessionId)!.set(ws, user || { id: uuidv4(), name: 'Guest' });
|
||||
|
||||
// Broadcast presence to the room
|
||||
const usersInRoom = Array.from(roomUsers.get(sessionId)!.values());
|
||||
rooms.get(sessionId)!.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({ type: 'presence', users: usersInRoom }));
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`User ${user?.name || 'Guest'} joined session: ${sessionId}`);
|
||||
}
|
||||
|
||||
if (data.type === 'idea_added' || data.type === 'idea_updated' || data.type === 'activity') {
|
||||
if (currentRoom && rooms.has(currentRoom)) {
|
||||
rooms.get(currentRoom)!.forEach(client => {
|
||||
if (client !== ws && client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(data));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (currentRoom && rooms.has(currentRoom)) {
|
||||
rooms.get(currentRoom)!.delete(ws);
|
||||
roomUsers.get(currentRoom)!.delete(ws);
|
||||
|
||||
// Update presence
|
||||
if (rooms.get(currentRoom)!.size > 0) {
|
||||
const usersInRoom = Array.from(roomUsers.get(currentRoom)!.values());
|
||||
rooms.get(currentRoom)!.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({ type: 'presence', users: usersInRoom }));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
rooms.delete(currentRoom);
|
||||
roomUsers.delete(currentRoom);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.get("/api/health", (req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// 1. Create session
|
||||
app.post("/api/brainstorm/sessions", (req, res) => {
|
||||
const { seedIdea, sourceNoteId, contextNoteIds } = req.body;
|
||||
|
||||
const session: BrainstormSession = {
|
||||
id: uuidv4(),
|
||||
seedIdea,
|
||||
sourceNoteId,
|
||||
contextNoteIds,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
sessions.unshift(session);
|
||||
res.json(session);
|
||||
});
|
||||
|
||||
// 2. Add ideas to session
|
||||
app.post("/api/brainstorm/:sessionId/ideas", (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const { ideas: newIdeasData } = req.body;
|
||||
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
if (!session) return res.status(404).json({ error: "Session not found" });
|
||||
|
||||
const newIdeas = newIdeasData.map((item: any) => ({
|
||||
id: item.id || uuidv4(),
|
||||
sessionId,
|
||||
waveNumber: item.waveNumber,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
connectionToSeed: item.connectionToSeed,
|
||||
noveltyScore: item.noveltyScore,
|
||||
parentIdeaId: item.parentIdeaId,
|
||||
status: 'active'
|
||||
}));
|
||||
|
||||
newIdeas.forEach((i: any) => ideas.push(i));
|
||||
res.json(newIdeas);
|
||||
});
|
||||
|
||||
// 3. Get all sessions
|
||||
app.get("/api/brainstorm/sessions", (req, res) => {
|
||||
res.json(sessions);
|
||||
});
|
||||
|
||||
// 4. Get session with ideas
|
||||
app.get("/api/brainstorm/:sessionId", (req, res) => {
|
||||
const session = sessions.find(s => s.id === req.params.sessionId);
|
||||
if (!session) return res.status(404).json({ error: "Session not found" });
|
||||
const sessionIdeas = ideas.filter(i => i.sessionId === session.id);
|
||||
res.json({ session, ideas: sessionIdeas });
|
||||
});
|
||||
|
||||
// 5. Update idea (position, status)
|
||||
app.patch("/api/brainstorm/ideas/:ideaId", (req, res) => {
|
||||
const index = ideas.findIndex(i => i.id === req.params.ideaId);
|
||||
if (index === -1) return res.status(404).json({ error: "Idea not found" });
|
||||
|
||||
ideas[index] = { ...ideas[index], ...req.body };
|
||||
res.json(ideas[index]);
|
||||
});
|
||||
|
||||
// Vite middleware for development
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const vite = await createViteServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "spa",
|
||||
});
|
||||
app.use(vite.middlewares);
|
||||
} else {
|
||||
const distPath = path.join(process.cwd(), 'dist');
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
619
architectural-grid14/src/App.tsx
Normal file
619
architectural-grid14/src/App.tsx
Normal file
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
// Components
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { NotebooksView } from './components/NotebooksView';
|
||||
import { AgentsView } from './components/AgentsView';
|
||||
import { SettingsView } from './components/SettingsView';
|
||||
import { TrashView } from './components/TrashView';
|
||||
import { BrainstormView } from './components/BrainstormView/BrainstormView';
|
||||
import { InsightsView } from './components/InsightsView';
|
||||
import { TemporalView } from './components/TemporalView';
|
||||
import { AISidebar } from './components/AISidebar';
|
||||
import { SlashMenu } from './components/SlashMenu';
|
||||
|
||||
// Data & Types
|
||||
import { CARNETS, ALL_NOTES } from './constants';
|
||||
import { NavigationView, SettingsTab, AITab, AITone, Carnet, Note, BrainstormIdea, NoteAccessLog } from './types';
|
||||
|
||||
export default function App() {
|
||||
const [activeView, setActiveView] = useState<NavigationView>('notebooks');
|
||||
const [activeSettingsTab, setActiveSettingsTab] = useState<SettingsTab>('general');
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [carnets, setCarnets] = useState<Carnet[]>(CARNETS);
|
||||
const [notes, setNotes] = useState<Note[]>(ALL_NOTES);
|
||||
const [accessLogs, setAccessLogs] = useState<NoteAccessLog[]>([
|
||||
// Note n1: 14-day cycle
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 70 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 56 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 42 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n1', accessedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
|
||||
// Note n2: 7-day cycle
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n2', accessedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
|
||||
// Note n3: 3-day cycle (frequent check)
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 12 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 9 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
{ noteId: 'n3', accessedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
|
||||
]);
|
||||
|
||||
const logNoteAccess = (noteId: string, action: 'view' | 'edit' | 'search_hit' = 'view') => {
|
||||
const newLog: NoteAccessLog = {
|
||||
noteId,
|
||||
accessedAt: new Date().toISOString(),
|
||||
action
|
||||
};
|
||||
setAccessLogs(prev => [...prev, newLog]);
|
||||
};
|
||||
|
||||
const [activeCarnetId, setActiveCarnetId] = useState('4');
|
||||
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
|
||||
const [brainstormSeed, setBrainstormSeed] = useState<string | null>(null);
|
||||
|
||||
const handleBrainstormNote = (note: Note) => {
|
||||
setActiveView('brainstorm');
|
||||
// We'll use a small delay or a ref to pass this to BrainstormView if needed,
|
||||
// but better to just share state or use a CustomEvent
|
||||
window.dispatchEvent(new CustomEvent('start-brainstorm', {
|
||||
detail: { seed: note.title, sourceNoteId: note.id }
|
||||
}));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeNoteId) {
|
||||
logNoteAccess(activeNoteId);
|
||||
}
|
||||
}, [activeNoteId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Check for session in URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const session = params.get('session');
|
||||
if (session) {
|
||||
setActiveView('brainstorm');
|
||||
// We pass it via a global property or custom event since BrainstormView will fetch sessions
|
||||
(window as any).initialSessionId = session;
|
||||
}
|
||||
|
||||
const handleSwitchView = (e: any) => {
|
||||
if (e.detail) {
|
||||
setActiveView(e.detail as NavigationView);
|
||||
}
|
||||
};
|
||||
window.addEventListener('switch-view', handleSwitchView);
|
||||
return () => window.removeEventListener('switch-view', handleSwitchView);
|
||||
}, []);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const [isAISidebarOpen, setIsAISidebarOpen] = useState(false);
|
||||
const [aiTab, setAiTab] = useState<AITab>('discussion');
|
||||
const [selectedTone, setSelectedTone] = useState<AITone>('Professional');
|
||||
|
||||
// Modal States
|
||||
const [showNewCarnetModal, setShowNewCarnetModal] = useState<{ isOpen: boolean; parentId?: string; isRenaming?: boolean; carnetId?: string }>({ isOpen: false });
|
||||
const [showNewNoteModal, setShowNewNoteModal] = useState(false);
|
||||
const [slashMenu, setSlashMenu] = useState<{ isOpen: boolean; top: number; left: number } | null>(null);
|
||||
|
||||
// Form States
|
||||
const [newCarnetName, setNewCarnetName] = useState('');
|
||||
const [newNoteTitle, setNewNoteTitle] = useState('');
|
||||
const [newNoteContent, setNewNoteContent] = useState('');
|
||||
|
||||
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === '/') {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
setSlashMenu({
|
||||
isOpen: true,
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const togglePin = (noteId: string) => {
|
||||
setNotes(notes.map(n => n.id === noteId ? { ...n, isPinned: !n.isPinned } : n));
|
||||
};
|
||||
|
||||
const filteredNotes = useMemo(() => {
|
||||
let result = notes.filter(n => n.carnetId === activeCarnetId && !n.isDeleted);
|
||||
|
||||
if (selectedTagIds.length > 0) {
|
||||
result = result.filter(note =>
|
||||
selectedTagIds.every(tagId => note.tags?.some(tag => tag.id === tagId))
|
||||
);
|
||||
}
|
||||
|
||||
return [...result].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [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;
|
||||
|
||||
if (showNewCarnetModal.isRenaming && showNewCarnetModal.carnetId) {
|
||||
setCarnets(carnets.map(c => c.id === showNewCarnetModal.carnetId ? { ...c, name: newCarnetName, initial: newCarnetName.charAt(0).toUpperCase() } : c));
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setNewCarnetName('');
|
||||
return;
|
||||
}
|
||||
|
||||
const newCarnet: Carnet = {
|
||||
id: Date.now().toString(),
|
||||
name: newCarnetName,
|
||||
initial: newCarnetName.charAt(0).toUpperCase(),
|
||||
type: 'Project',
|
||||
parentId: showNewCarnetModal.parentId
|
||||
};
|
||||
|
||||
setCarnets([...carnets, newCarnet]);
|
||||
setNewCarnetName('');
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setActiveCarnetId(newCarnet.id);
|
||||
};
|
||||
|
||||
const handleDeleteCarnet = (id: string) => {
|
||||
if (window.confirm('Déplacer ce carnet et ses sous-carnets vers la corbeille ?')) {
|
||||
const idsToDelete = new Set<string>([id]);
|
||||
|
||||
const addChildren = (parentId: string) => {
|
||||
carnets.forEach(c => {
|
||||
if (c.parentId === parentId) {
|
||||
idsToDelete.add(c.id);
|
||||
addChildren(c.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
addChildren(id);
|
||||
|
||||
const deletedAt = new Date().toISOString();
|
||||
setCarnets(carnets.map(c => idsToDelete.has(c.id) ? { ...c, isDeleted: true, deletedAt } : c));
|
||||
setNotes(notes.map(n => idsToDelete.has(n.carnetId) ? { ...n, isDeleted: true, deletedAt } : n));
|
||||
|
||||
if (idsToDelete.has(activeCarnetId)) {
|
||||
setActiveCarnetId('1');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNote = (id: string) => {
|
||||
const deletedAt = new Date().toISOString();
|
||||
setNotes(notes.map(n => n.id === id ? { ...n, isDeleted: true, deletedAt } : n));
|
||||
if (activeNoteId === id) setActiveNoteId(null);
|
||||
};
|
||||
|
||||
const handleRestoreCarnet = (id: string) => {
|
||||
setCarnets(carnets.map(c => c.id === id ? { ...c, isDeleted: false, deletedAt: undefined } : c));
|
||||
// Optionally restore linked notes too? User might expect that.
|
||||
setNotes(notes.map(n => n.carnetId === id ? { ...n, isDeleted: false, deletedAt: undefined } : n));
|
||||
};
|
||||
|
||||
const handleRestoreNote = (id: string) => {
|
||||
setNotes(notes.map(n => n.id === id ? { ...n, isDeleted: false, deletedAt: undefined } : n));
|
||||
};
|
||||
|
||||
const handlePermanentDeleteNote = (id: string) => {
|
||||
setNotes(notes.filter(n => n.id !== id));
|
||||
};
|
||||
|
||||
const handlePermanentDeleteCarnet = (id: string) => {
|
||||
const idsToDelete = new Set<string>([id]);
|
||||
const addChildren = (parentId: string) => {
|
||||
carnets.forEach(c => {
|
||||
if (c.parentId === parentId) {
|
||||
idsToDelete.add(c.id);
|
||||
addChildren(c.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
addChildren(id);
|
||||
setCarnets(carnets.filter(c => !idsToDelete.has(c.id)));
|
||||
setNotes(notes.filter(n => !idsToDelete.has(n.carnetId)));
|
||||
};
|
||||
|
||||
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',
|
||||
tags: []
|
||||
};
|
||||
|
||||
setNotes([newNote, ...notes]);
|
||||
setNewNoteTitle('');
|
||||
setNewNoteContent('');
|
||||
setShowNewNoteModal(false);
|
||||
setActiveNoteId(newNote.id);
|
||||
};
|
||||
|
||||
const handleConvertIdeaToNote = (idea: BrainstormIdea) => {
|
||||
const newNote: Note = {
|
||||
id: `n-gen-${Date.now()}`,
|
||||
carnetId: activeCarnetId,
|
||||
title: idea.title,
|
||||
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
|
||||
content: `${idea.description}\n\n---\n**Connection to seed:** ${idea.connectionToSeed}\n**Novelty Score:** ${idea.noveltyScore}/10`,
|
||||
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't-ai', label: 'AI Generated', type: 'ai' }]
|
||||
};
|
||||
|
||||
setNotes([newNote, ...notes]);
|
||||
setActiveView('notebooks');
|
||||
setActiveNoteId(newNote.id);
|
||||
};
|
||||
|
||||
const handleUpdateNote = (updatedNote: Note) => {
|
||||
setNotes(notes.map(n => n.id === updatedNote.id ? updatedNote : n));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`h-screen flex bg-paper transition-colors duration-500 overflow-hidden font-sans ${isDarkMode ? 'dark' : ''}`}>
|
||||
<Sidebar
|
||||
activeView={activeView}
|
||||
isDarkMode={isDarkMode}
|
||||
setIsDarkMode={setIsDarkMode}
|
||||
setActiveView={setActiveView}
|
||||
carnets={carnets}
|
||||
notes={notes}
|
||||
activeCarnetId={activeCarnetId}
|
||||
activeNoteId={activeNoteId}
|
||||
setActiveCarnetId={setActiveCarnetId}
|
||||
setActiveNoteId={setActiveNoteId}
|
||||
setShowNewCarnetModal={(show, parentId, isRenaming, carnetId) => {
|
||||
setShowNewCarnetModal({ isOpen: show, parentId, isRenaming, carnetId });
|
||||
if (isRenaming && carnetId) {
|
||||
const carnet = carnets.find(c => c.id === carnetId);
|
||||
if (carnet) setNewCarnetName(carnet.name);
|
||||
} else {
|
||||
setNewCarnetName('');
|
||||
}
|
||||
}}
|
||||
onDeleteCarnet={handleDeleteCarnet}
|
||||
onMoveCarnet={(draggedId, targetId) => {
|
||||
if (draggedId === targetId) return;
|
||||
|
||||
// Basic circular check
|
||||
const isDescendant = (parentId: string, potentialChildId: string): boolean => {
|
||||
const childIds = carnets.filter(c => c.parentId === parentId).map(c => c.id);
|
||||
if (childIds.includes(potentialChildId)) return true;
|
||||
return childIds.some(id => isDescendant(id, potentialChildId));
|
||||
};
|
||||
|
||||
if (targetId && isDescendant(draggedId, targetId)) {
|
||||
console.warn("Cannot move a notebook inside its own descendant");
|
||||
return;
|
||||
}
|
||||
|
||||
setCarnets(prev => prev.map(c => c.id === draggedId ? { ...c, parentId: targetId } : c));
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="flex-1 relative overflow-hidden flex bg-paper dark:bg-dark-paper transition-colors duration-500">
|
||||
<AnimatePresence mode="wait">
|
||||
{(activeView === 'notebooks' || activeView === 'shared' || activeView === 'reminders') && (
|
||||
<motion.div
|
||||
key={activeView}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<NotebooksView
|
||||
activeNoteId={activeNoteId}
|
||||
activeCarnet={activeCarnet}
|
||||
filteredNotes={filteredNotes}
|
||||
activeNote={activeNote}
|
||||
setActiveNoteId={setActiveNoteId}
|
||||
togglePin={togglePin}
|
||||
setShowNewNoteModal={setShowNewNoteModal}
|
||||
isAISidebarOpen={isAISidebarOpen}
|
||||
setIsAISidebarOpen={setIsAISidebarOpen}
|
||||
selectedTagIds={selectedTagIds}
|
||||
setSelectedTagIds={setSelectedTagIds}
|
||||
allNotes={notes}
|
||||
activeCarnetId={activeCarnetId}
|
||||
setShowNewCarnetModal={(show, parentId, isRenaming, carnetId) => setShowNewCarnetModal({ isOpen: show, parentId, isRenaming, carnetId })}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
onBrainstormNote={handleBrainstormNote}
|
||||
onUpdateNote={handleUpdateNote}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'trash' && (
|
||||
<motion.div
|
||||
key="trash"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TrashView
|
||||
deletedNotes={notes.filter(n => n.isDeleted)}
|
||||
deletedCarnets={carnets.filter(c => c.isDeleted)}
|
||||
onRestoreNote={handleRestoreNote}
|
||||
onRestoreCarnet={handleRestoreCarnet}
|
||||
onPermanentDeleteNote={handlePermanentDeleteNote}
|
||||
onPermanentDeleteCarnet={handlePermanentDeleteCarnet}
|
||||
onEmptyTrash={() => {
|
||||
setNotes(notes.filter(n => !n.isDeleted));
|
||||
setCarnets(carnets.filter(c => !c.isDeleted));
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'agents' && (
|
||||
<motion.div
|
||||
key="agents"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<AgentsView
|
||||
selectedAgentId={selectedAgentId}
|
||||
setSelectedAgentId={setSelectedAgentId}
|
||||
carnets={carnets}
|
||||
notes={notes}
|
||||
onAddNote={(note) => setNotes([note, ...notes])}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'settings' && (
|
||||
<motion.div
|
||||
key="settings"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<SettingsView
|
||||
activeSettingsTab={activeSettingsTab}
|
||||
setActiveSettingsTab={setActiveSettingsTab}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'brainstorm' && (
|
||||
<motion.div
|
||||
key="brainstorm"
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<BrainstormView
|
||||
notes={notes}
|
||||
onConvertNote={handleConvertIdeaToNote}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'insights' && (
|
||||
<motion.div
|
||||
key="insights"
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<InsightsView
|
||||
notes={notes}
|
||||
onUpdateNotes={setNotes}
|
||||
onNoteSelect={(noteId) => {
|
||||
setActiveView('notebooks');
|
||||
setActiveNoteId(noteId);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeView === 'temporal' && (
|
||||
<motion.div
|
||||
key="temporal"
|
||||
initial={{ opacity: 0, scale: 1.05 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<TemporalView
|
||||
notes={notes}
|
||||
accessLogs={accessLogs}
|
||||
onNoteSelect={(noteId) => {
|
||||
setActiveView('notebooks');
|
||||
setActiveNoteId(noteId);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AISidebar
|
||||
isOpen={isAISidebarOpen}
|
||||
setIsOpen={setIsAISidebarOpen}
|
||||
activeNote={activeNote}
|
||||
aiTab={aiTab}
|
||||
setAiTab={setAiTab}
|
||||
selectedTone={selectedTone}
|
||||
setSelectedTone={setSelectedTone}
|
||||
carnets={carnets}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
<AnimatePresence>
|
||||
{showNewCarnetModal.isOpen && (
|
||||
<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({ isOpen: 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 dark:bg-dark-paper border border-border shadow-2xl rounded-2xl p-8"
|
||||
>
|
||||
<h3 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink mb-2">
|
||||
{showNewCarnetModal.isRenaming ? 'Rename Carnet' : (showNewCarnetModal.parentId ? 'Create Sub-Carnet' : 'Create New Carnet')}
|
||||
</h3>
|
||||
{showNewCarnetModal.parentId && !showNewCarnetModal.isRenaming && (
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest font-bold mb-6">
|
||||
Inside: {carnets.find(c => c.id === showNewCarnetModal.parentId)?.name}
|
||||
</p>
|
||||
)}
|
||||
<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 dark:bg-[#2A2A2A] border border-border rounded-lg px-4 py-3 outline-none focus:border-ink transition-colors font-serif italic text-lg text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowNewCarnetModal({ isOpen: false });
|
||||
setNewCarnetName('');
|
||||
}}
|
||||
className="flex-1 py-3 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 dark:hover:bg-white/5 transition-colors text-ink dark:text-dark-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-3 bg-ink dark:bg-ochre text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{showNewCarnetModal.isRenaming ? 'Rename' : '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 dark:bg-dark-paper border border-border shadow-2xl rounded-2xl p-10"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{slashMenu?.isOpen && (
|
||||
<SlashMenu
|
||||
position={{ top: slashMenu.top, left: slashMenu.left }}
|
||||
onSelect={(type) => { console.log(type); setSlashMenu(null); }}
|
||||
onClose={() => setSlashMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<h3 className="text-3xl font-serif font-medium text-ink dark:text-dark-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 dark:bg-[#2A2A2A] border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-serif text-2xl text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</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)}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
placeholder="Describe the spatial logic, materiality, and light interactions... (Type '/' for commands)"
|
||||
rows={6}
|
||||
className="w-full bg-white dark:bg-[#2A2A2A] border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-light leading-relaxed resize-none text-ink dark:text-dark-ink"
|
||||
/>
|
||||
</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 dark:hover:bg-white/5 transition-colors text-ink dark:text-dark-ink"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-4 bg-ink dark:bg-ochre text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Save Note
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
466
architectural-grid14/src/components/AISidebar.tsx
Normal file
466
architectural-grid14/src/components/AISidebar.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
FileCode,
|
||||
Globe,
|
||||
Send,
|
||||
Scissors,
|
||||
Zap,
|
||||
Languages,
|
||||
Layout,
|
||||
ArrowRightLeft,
|
||||
BookOpen,
|
||||
History,
|
||||
Target,
|
||||
Network,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { AITab, AITone, Note, Carnet } from '../types';
|
||||
import { HierarchicalCarnetSelector } from './HierarchicalCarnetSelector';
|
||||
|
||||
interface AISidebarProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
activeNote: Note | undefined;
|
||||
aiTab: AITab;
|
||||
setAiTab: (tab: AITab) => void;
|
||||
selectedTone: AITone;
|
||||
setSelectedTone: (tone: AITone) => void;
|
||||
carnets: Carnet[];
|
||||
}
|
||||
|
||||
export const AISidebar: React.FC<AISidebarProps> = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
activeNote,
|
||||
aiTab,
|
||||
setAiTab,
|
||||
selectedTone,
|
||||
setSelectedTone,
|
||||
carnets
|
||||
}) => {
|
||||
const [selectedContextId, setSelectedContextId] = React.useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<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"
|
||||
>
|
||||
<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-ochre" />
|
||||
IA Assistant
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(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>
|
||||
|
||||
<div className="flex border-b border-border px-2">
|
||||
{(['discussion', 'actions', 'explore', 'resources'] as AITab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setAiTab(tab)}
|
||||
className={`flex-1 py-3 text-[10px] uppercase tracking-[0.2em] font-bold transition-all relative
|
||||
${aiTab === tab ? 'text-manganese' : '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-ochre"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
<AnimatePresence mode="wait">
|
||||
{aiTab === 'explore' && (
|
||||
<motion.div
|
||||
key="explore"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<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">Intelligence Modules</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
// These will be handled in App.tsx by observing activeView
|
||||
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'brainstorm' }));
|
||||
}}
|
||||
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-ochre/30 transition-all text-left overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Zap size={60} className="text-ochre" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-ochre/10 rounded-xl text-ochre group-hover:bg-ochre group-hover:text-white transition-colors">
|
||||
<Zap size={20} fill="currentColor" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">Brainstorm Wave</h5>
|
||||
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Unfold dimensions of thought</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'insights' }));
|
||||
}}
|
||||
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-indigo-500/30 transition-all text-left overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Network size={60} className="text-indigo-500" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-indigo-500/10 rounded-xl text-indigo-500 group-hover:bg-indigo-500 group-hover:text-white transition-colors">
|
||||
<Network size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">Semantic Network</h5>
|
||||
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Detect clusters and bridges</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'temporal' }));
|
||||
}}
|
||||
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-rose-500/30 transition-all text-left overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Clock size={60} className="text-rose-500" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-rose-500/10 rounded-xl text-rose-500 group-hover:bg-rose-500 group-hover:text-white transition-colors">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">Temporal Forecast</h5>
|
||||
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Predict relevance recurrence</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-50 dark:bg-white/5 border border-dashed border-border mt-6">
|
||||
<p className="text-[10px] text-muted-ink leading-relaxed font-medium italic text-center">
|
||||
Ces modules utilisent les embeddings du modèle Gemini pour analyser graphiquement vos pensées.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{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">Source du Contexte</label>
|
||||
<div className="space-y-3">
|
||||
<div className="w-full p-3 bg-glass border border-border rounded-lg text-xs flex items-center justify-between cursor-default backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode size={14} className="text-blueprint" />
|
||||
<span className="font-medium text-ink">Note Active</span>
|
||||
</div>
|
||||
<div className="text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter italic">Auto</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">+ Carnet</span>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<HierarchicalCarnetSelector
|
||||
carnets={carnets}
|
||||
selectedId={selectedContextId}
|
||||
onSelect={setSelectedContextId}
|
||||
placeholder="Inclure un carnet..."
|
||||
className="w-full"
|
||||
/>
|
||||
</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-manganese text-paper border-manganese shadow-lg shadow-manganese/10' : 'bg-glass border-border text-muted-ink hover:border-ink/20'}`}
|
||||
>
|
||||
{tone.toUpperCase().substring(0, 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">
|
||||
<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', color: 'ochre' },
|
||||
{ icon: <Scissors size={14} />, label: 'Raccourcir', color: 'rust' },
|
||||
{ icon: <Zap size={14} />, label: 'Améliorer', color: 'sage' },
|
||||
{ icon: <Languages size={14} />, label: 'Traduire', color: 'slate' },
|
||||
].map((action, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="flex flex-col items-center gap-3 p-4 bg-glass border border-border rounded-xl transition-all group hover:border-ink/20"
|
||||
>
|
||||
<div className={`p-2 rounded-lg bg-slate-50 dark:bg-white/10 transition-colors group-hover:bg-manganese group-hover:text-paper shadow-sm text-ink/60`}>
|
||||
{action.icon}
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-ink/80 uppercase tracking-widest">{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="col-span-2 flex items-center justify-center gap-3 py-3 px-4 bg-glass border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-slate-50 dark:hover:bg-white/10 transition-colors hover:border-ink/20 uppercase tracking-widest">
|
||||
<FileCode size={14} className="text-muted-ink" />
|
||||
Convertir en Markdown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-blueprint/30 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-blueprint" />
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-blueprint">
|
||||
<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-[0.2em] font-bold text-muted-ink/60 px-1">Thème</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-blueprint/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-[0.2em] font-bold text-muted-ink/60 px-1">Style</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-blueprint/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-blueprint text-paper rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-blueprint/20 uppercase tracking-[0.2em]">
|
||||
Générer
|
||||
<ArrowRightLeft size={14} className="opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-sage/30 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-sage" />
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-sage">
|
||||
<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-[0.2em] font-bold text-muted-ink/60 px-1">Type</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-sage/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-[0.2em] font-bold text-muted-ink/60 px-1">Style</span>
|
||||
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-sage/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-sage text-paper rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-sage/20 uppercase tracking-[0.2em]">
|
||||
Tracer
|
||||
<ArrowRightLeft size={14} className="opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-glass border border-border rounded-lg pl-3 pr-10 py-3 text-xs outline-none focus:border-blueprint 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-glass border border-border rounded-lg p-4 text-xs outline-none focus:border-blueprint 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-sage/10 border-sage/50 ring-1 ring-sage/10' : 'bg-white border-border hover:bg-slate-50'}`}>
|
||||
<span className={`text-[11px] font-bold ${mode.id === 'append' ? 'text-sage' : '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-blueprint text-white rounded-xl text-sm font-bold flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-blueprint/20">
|
||||
<Sparkles size={18} />
|
||||
Générer l'aperçu
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<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-glass backdrop-blur-sm border border-border rounded-2xl p-4 pr-12 text-sm outline-none focus:border-blueprint 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-blueprint text-white rounded-lg transition-transform hover:scale-105 active:scale-95 shadow-lg shadow-blueprint/10">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
385
architectural-grid14/src/components/AgentsView.tsx
Normal file
385
architectural-grid14/src/components/AgentsView.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
Activity,
|
||||
Trash2,
|
||||
Edit3,
|
||||
Play,
|
||||
Eye,
|
||||
Microscope,
|
||||
Globe,
|
||||
Layers,
|
||||
Zap,
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Info,
|
||||
Check,
|
||||
ClipboardCheck,
|
||||
ListTodo,
|
||||
ArrowRight,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Carnet, Note } from '../types';
|
||||
import { HierarchicalCarnetSelector } from './HierarchicalCarnetSelector';
|
||||
import { extractActionItems } from '../services/geminiService';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface AgentsViewProps {
|
||||
selectedAgentId: string | null;
|
||||
setSelectedAgentId: (id: string | null) => void;
|
||||
carnets: Carnet[];
|
||||
notes: Note[];
|
||||
onAddNote?: (note: Note) => void;
|
||||
}
|
||||
|
||||
export const AgentsView: React.FC<AgentsViewProps> = ({
|
||||
selectedAgentId,
|
||||
setSelectedAgentId,
|
||||
carnets,
|
||||
notes,
|
||||
onAddNote
|
||||
}) => {
|
||||
const [selectedCarnetForAgent, setSelectedCarnetForAgent] = React.useState<string | null>('4');
|
||||
const [agentType, setAgentType] = React.useState<'Surveillant' | 'Personnalisé' | 'Slides' | 'Diagramme' | 'Tasks'>('Tasks');
|
||||
const [isRunningAgent, setIsRunningAgent] = React.useState(false);
|
||||
const [agentResult, setAgentResult] = React.useState<string | null>(null);
|
||||
|
||||
const handleRunTaskAgent = async () => {
|
||||
setIsRunningAgent(true);
|
||||
setAgentResult(null);
|
||||
|
||||
// Get notes from carnet
|
||||
const filteredNotes = notes.filter(n => n.carnetId === selectedCarnetForAgent && !n.isDeleted);
|
||||
|
||||
try {
|
||||
const result = await extractActionItems(filteredNotes);
|
||||
setAgentResult(result);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsRunningAgent(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveResult = () => {
|
||||
if (!agentResult || !onAddNote) return;
|
||||
|
||||
const carnetName = carnets.find(c => c.id === selectedCarnetForAgent)?.name || 'General';
|
||||
|
||||
const newNote: Note = {
|
||||
id: uuidv4(),
|
||||
carnetId: selectedCarnetForAgent || '1',
|
||||
title: `Consolidated Actions: ${carnetName}`,
|
||||
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
|
||||
content: agentResult,
|
||||
imageUrl: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
tags: [{ id: 't-tasks', label: 'Action Items', type: 'ai' }]
|
||||
};
|
||||
|
||||
onAddNote(newNote);
|
||||
alert('Note de tâches créée avec succès !');
|
||||
setSelectedAgentId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-y-auto custom-scrollbar bg-[#F9F8F6] dark:bg-dark-paper space-y-12">
|
||||
{!selectedAgentId ? (
|
||||
<>
|
||||
<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-end">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink">Mes Agents</h1>
|
||||
<p className="text-sm text-muted-ink font-light">Automatisez vos tâches de veille et de recherche.</p>
|
||||
</div>
|
||||
<button className="px-6 py-2.5 bg-ink text-paper text-sm font-medium rounded-xl hover:opacity-90 transition-all flex items-center gap-3 shadow-lg shadow-ink/10">
|
||||
<Plus size={18} />
|
||||
Nouvel Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 border-b border-ink/5 pt-4">
|
||||
{['Tous', 'Veilleur', 'Chercheur', 'Surveillant', 'Personnalisé'].map((tag, i) => (
|
||||
<button key={i} className={`pb-4 text-xs font-bold uppercase tracking-widest transition-all relative ${i === 0 ? 'text-ink' : 'text-muted-ink hover:text-ink/60'}`}>
|
||||
{tag}
|
||||
{i === 0 && <motion.div layoutId="activeAgentTag" className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-12 flex-1 pb-20 space-y-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ id: 'a1', icon: <Eye size={20} className="text-amber-600" />, title: 'Surveillant de Notes', status: 'Réussi', type: 'SURVEILLANT', meta: 'Hebdomadaire • 6 exéc.', desc: 'Analyse les notes récentes d’un carnet et suggère des compléments, références et liens.' },
|
||||
{ id: 'a2', icon: <Microscope size={20} className="text-indigo-600" />, title: 'Chercheur de Sujet', status: 'Réussi', type: 'CHERCHEUR', meta: 'Hebdomadaire • 14 exéc.', desc: 'Recherche des informations approfondies sur les derniers modèles de Deepseek et voir l’avis des utilisateurs.' },
|
||||
{ id: 'a3', icon: <Globe size={20} className="text-emerald-600" />, title: 'Veille IA', status: 'Réussi', type: 'VEILLEUR', meta: 'Quotidien • 20 exéc.', desc: 'Scrape les flux RSS de 6 sites IA (The Verge, TechCrunch...) et génère un résumé.' },
|
||||
{ id: 'a4', icon: <ClipboardCheck size={20} className="text-rose-500" />, title: 'Action Miner', status: 'Inactif', type: 'TASKS', meta: 'À la demande', desc: 'Scan vos notes pour extraire automatiquement les tâches, assignés et deadlines.' },
|
||||
].map((agent, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
className="bg-white dark:bg-white/5 border border-border rounded-2xl p-6 space-y-6 hover:border-ink/20 transition-all group cursor-pointer shadow-sm relative overflow-hidden"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-slate-50 dark:bg-white/10 rounded-xl group-hover:bg-ink group-hover:text-paper transition-all">
|
||||
{agent.icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-ink">{agent.title}</h4>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-ink opacity-60">{agent.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-8 h-4 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-emerald-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-ink leading-relaxed line-clamp-3">
|
||||
{agent.desc}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-ink font-medium">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1"><Clock size={10} /> {agent.meta.split('•')[0]}</span>
|
||||
<span>{agent.meta.split('•')[1]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-ink font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="uppercase tracking-tight">Prochaine exécution</span>
|
||||
<span className="text-ink">Hebdomadaire</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="uppercase tracking-tight">Dernier statut</span>
|
||||
<span className="text-emerald-600 flex items-center gap-1"><Activity size={8} /> {agent.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-t border-border pt-4">
|
||||
<button className="py-2 border border-border rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 flex items-center justify-center transition-colors text-muted-ink hover:text-ink"><Edit3 size={14} /> <span className="ml-2 text-[10px] font-bold uppercase">Modifier</span></button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
className="py-2 border border-border rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 flex items-center justify-center transition-colors text-muted-ink hover:text-ink"
|
||||
>
|
||||
<Play size={14} className="fill-current" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
className="py-2 border border-border rounded-lg hover:bg-rose-50 hover:text-rose-600 hover:border-rose-100 flex items-center justify-center transition-colors text-muted-ink"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<h5 className="text-[10px] font-bold uppercase tracking-[0.3em] text-muted-ink whitespace-nowrap">Modèles</h5>
|
||||
<div className="h-px w-full bg-border/40" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ title: 'Veille IA', desc: 'Scrape les flux RSS de 6 sites IA et génère un résumé hebdomadaire.', icon: <Globe size={18} /> },
|
||||
{ title: 'Veille Tech', desc: 'Crée un résumé quotidien des news Hacker News et Product Hunt.', icon: <Zap size={18} /> },
|
||||
{ title: 'Veille Dev', desc: 'Surveille les repos GitHub pour détecter les nouvelles releases.', icon: <Layers size={18} /> },
|
||||
].map((model, i) => (
|
||||
<div key={i} className="bg-white/40 dark:bg-white/5 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-white dark:hover:bg-white/10 hover:border-ink/20 transition-all">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-50 dark:bg-white/10 flex items-center justify-center text-muted-ink group-hover:bg-ink group-hover:text-paper mb-4 transition-all">
|
||||
{model.icon}
|
||||
</div>
|
||||
<h4 className="text-[13px] font-bold text-ink mb-2">{model.title}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-4">{model.desc}</p>
|
||||
<button className="text-[11px] font-bold uppercase tracking-widest text-ink hover:opacity-60 transition-opacity flex items-center gap-2">
|
||||
<Plus size={14} /> Installer
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<header className="px-12 py-10 border-b border-border bg-white dark:bg-paper backdrop-blur-md sticky top-0 z-30">
|
||||
<div className="flex items-center justify-between max-w-5xl mx-auto">
|
||||
<button
|
||||
onClick={() => setSelectedAgentId(null)}
|
||||
className="flex items-center gap-3 text-xs font-bold uppercase tracking-widest text-muted-ink hover:text-ink transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Retour
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="px-5 py-2 text-xs font-bold uppercase tracking-widest border border-border rounded-xl hover:bg-slate-50 dark:hover:bg-white/5 transition-all">
|
||||
Logs
|
||||
</button>
|
||||
<button className="px-6 py-2 bg-ink text-paper text-xs font-bold uppercase tracking-widest rounded-xl hover:opacity-90 transition-all shadow-lg shadow-ink/10">
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 px-12 py-16 max-w-5xl mx-auto w-full space-y-24">
|
||||
<section className="space-y-12">
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete">Sélectionnez le type d'agent</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-4xl mx-auto">
|
||||
{[
|
||||
{ id: 'Surveillant', icon: <Eye size={18} />, label: 'Surveillant', desc: 'Surveille un carnet et analyse les notes' },
|
||||
{ id: 'Tasks', icon: <ListTodo size={18} />, label: 'Action Items', desc: 'Extrait les tâches et deadlines' },
|
||||
{ id: 'Slides', icon: <Layers size={18} />, label: 'Slides', desc: 'Crée une présentation PowerPoint à partir de notes' },
|
||||
{ id: 'Diagramme', icon: <Zap size={18} />, label: 'Diagramme', desc: 'Crée un diagramme Excalidraw à partir de notes' },
|
||||
].map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setAgentType(type.id as any)}
|
||||
className={`p-6 rounded-2xl border-2 transition-all flex flex-col items-center gap-3 text-center group relative
|
||||
${agentType === type.id ? 'border-blueprint bg-white shadow-xl shadow-blueprint/10' : 'border-border bg-white/50 hover:bg-white'}`}
|
||||
>
|
||||
<div className={`p-3 rounded-xl transition-all ${agentType === type.id ? 'bg-blueprint text-white' : 'bg-slate-50 text-concrete group-hover:text-ink'}`}>
|
||||
{type.icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[13px] font-bold text-ink">{type.label}</p>
|
||||
<p className="text-[10px] text-muted-ink leading-tight">{type.desc}</p>
|
||||
</div>
|
||||
<div className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all
|
||||
${agentType === type.id ? 'border-blueprint' : 'border-border opacity-20'}`}>
|
||||
{agentType === type.id && <div className="w-2 h-2 bg-blueprint rounded-full" />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
|
||||
CONFIGURATION <Info size={12} className="opacity-40" />
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-6 py-2 border-2 border-rose-100 bg-rose-50 rounded-xl text-rose-500 text-[11px] font-bold uppercase tracking-widest hover:bg-rose-100 transition-colors">
|
||||
<Trash2 size={14} /> Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-white/5 border border-border/60 rounded-[32px] p-12 space-y-12 shadow-sm">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">DESCRIPTION (OPTIONEL)</label>
|
||||
<Info size={12} className="text-concrete/40" />
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-black/20 border border-border/40 rounded-2xl p-6 text-sm outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all font-light leading-relaxed resize-none text-ink"
|
||||
placeholder="Décrivez brièvement le rôle de cet agent..."
|
||||
defaultValue="Lit une note et génère un diagramme visuel dans le Lab Excalidraw."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">CARNET À SURVEILLER</label>
|
||||
<Info size={12} className="text-concrete/40" />
|
||||
</div>
|
||||
<HierarchicalCarnetSelector
|
||||
carnets={carnets}
|
||||
selectedId={selectedCarnetForAgent}
|
||||
onSelect={setSelectedCarnetForAgent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">NOTES À ANALYSER</label>
|
||||
<Info size={12} className="text-concrete/40" />
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-black/20 border border-border/40 rounded-2xl overflow-hidden divide-y divide-border/20">
|
||||
{[
|
||||
'Résumé du conteneur LXC devSandbox',
|
||||
'Connexion SSH sans mot de passe à devSandbox',
|
||||
'Gateway token (blank to generate)',
|
||||
'Procédure d\'accès à openclaw',
|
||||
'Derniers commits du repo Momento'
|
||||
].map((note, i) => (
|
||||
<label key={i} className="flex items-center gap-4 px-6 py-4 cursor-pointer hover:bg-white/50 transition-colors group">
|
||||
<div className={`w-5 h-5 rounded border transition-all flex items-center justify-center
|
||||
${i === 0 ? 'bg-blueprint border-blueprint text-white' : 'bg-white border-border group-hover:border-blueprint/40'}`}>
|
||||
{i === 0 && <Check size={12} />}
|
||||
</div>
|
||||
<input type="checkbox" className="hidden" defaultChecked={i === 0} />
|
||||
<span className={`text-[13px] transition-colors ${i === 0 ? 'font-medium text-ink' : 'text-muted-ink'}`}>{note}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-concrete/60 italic font-medium">{1} note(s) sélectionnée(s)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">TYPE DE DIAGRAMME</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
|
||||
{[
|
||||
'Auto (détection métier)', 'Flowchart (processus)',
|
||||
'Mindmap (idées)', 'Organigramme (équipes)',
|
||||
'Timeline / roadmap', 'Process map (opérations)',
|
||||
'Architecture cloud (zones/RG)'
|
||||
].map((type, i) => (
|
||||
<button key={i} className={`px-6 py-4 rounded-xl border text-[13px] text-left transition-all
|
||||
${i === 0 ? 'border-ink bg-slate-50 font-bold text-ink ring-2 ring-ink/5' : 'border-border text-concrete hover:border-concrete/40 hover:bg-slate-50/50'}`}>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 border-t border-border flex flex-col items-center gap-6">
|
||||
{agentResult ? (
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-[10px] font-bold uppercase tracking-widest text-concrete">Résultat de l'extraction</h5>
|
||||
<button onClick={handleSaveResult} className="text-blueprint hover:underline text-[10px] font-bold uppercase tracking-widest flex items-center gap-1">
|
||||
<ArrowRight size={12} /> Sauvegarder dans une note
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-black/20 p-6 rounded-2xl border border-blueprint/20 font-serif text-sm text-ink leading-relaxed whitespace-pre-wrap max-h-96 overflow-y-auto">
|
||||
{agentResult}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRunTaskAgent}
|
||||
disabled={isRunningAgent}
|
||||
className="px-12 py-4 bg-blueprint text-white rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:opacity-90 transition-all shadow-xl shadow-blueprint/20 flex items-center gap-4 disabled:opacity-50"
|
||||
>
|
||||
{isRunningAgent ? <Loader2 size={18} className="animate-spin" /> : <Play size={18} fill="currentColor" />}
|
||||
Lancer l'extraction d'actions
|
||||
</button>
|
||||
)}
|
||||
<p className="text-[10px] text-concrete/60 italic">Cet agent analysera toutes les notes du carnet sélectionné.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,797 @@
|
||||
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Zap,
|
||||
Search,
|
||||
ArrowRight,
|
||||
History,
|
||||
Plus,
|
||||
Wind,
|
||||
PlusCircle,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Share2,
|
||||
Users,
|
||||
Check,
|
||||
Download,
|
||||
Activity,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { WaveCanvas } from './WaveCanvas';
|
||||
import { BrainstormSession, BrainstormIdea, Note } from '../../types';
|
||||
import { generateBrainstormWave, generateExpansion, getEmbedding, cosineSimilarity } from '../../services/geminiService';
|
||||
|
||||
interface BrainstormViewProps {
|
||||
notes: Note[];
|
||||
onConvertNote: (idea: BrainstormIdea) => void;
|
||||
}
|
||||
|
||||
export const BrainstormView: React.FC<BrainstormViewProps> = ({ notes, onConvertNote }) => {
|
||||
const [seedInput, setSeedInput] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessions, setSessions] = useState<BrainstormSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [ideas, setIdeas] = useState<BrainstormIdea[]>([]);
|
||||
const [selectedIdeaId, setSelectedIdeaId] = useState<string | null>(null);
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
|
||||
const [manualTitle, setManualTitle] = useState('');
|
||||
const [shareStatus, setShareStatus] = useState<'idle' | 'copying' | 'copied'>('idle');
|
||||
const [showActivity, setShowActivity] = useState(false);
|
||||
const [activities, setActivities] = useState<{ id: string; type: string; message: string; timestamp: string }[]>([]);
|
||||
const [collaborators, setCollaborators] = useState<{ id: string; name: string; color: string }[]>([]);
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// Mock current user for presence
|
||||
const currentUser = useMemo(() => ({
|
||||
id: 'me-' + Math.random().toString(36).substr(2, 9),
|
||||
name: 'Sepehr' // Derived from user email in metadata if possible, or guest
|
||||
}), []);
|
||||
|
||||
const getInitials = (name: string) => name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
const stringToColor = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const colors = ['#f43f5e', '#ef4444', '#f59e0b', '#10b981', '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#d946ef', '#f472b6'];
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
};
|
||||
|
||||
const addActivity = (message: string, type: string = 'info', broadcast: boolean = true) => {
|
||||
const newActivity = {
|
||||
id: uuidv4(),
|
||||
type,
|
||||
message,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
};
|
||||
setActivities(prev => [newActivity, ...prev].slice(0, 50));
|
||||
if (broadcast && socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: 'activity', activity: newActivity }));
|
||||
}
|
||||
};
|
||||
|
||||
// WebSocket Connection
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('WS Shared Brainstorm connected');
|
||||
if (activeSessionId) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'join',
|
||||
sessionId: activeSessionId,
|
||||
user: { ...currentUser, color: stringToColor(currentUser.name) }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'presence') {
|
||||
setCollaborators(data.users);
|
||||
}
|
||||
if (data.type === 'idea_added') {
|
||||
const newIdea = data.idea;
|
||||
setIdeas(prev => {
|
||||
if (prev.find(i => i.id === newIdea.id)) return prev;
|
||||
return [...prev, newIdea];
|
||||
});
|
||||
}
|
||||
if (data.type === 'idea_updated') {
|
||||
const updatedIdea = data.idea;
|
||||
setIdeas(prev => prev.map(i => i.id === updatedIdea.id ? updatedIdea : i));
|
||||
}
|
||||
if (data.type === 'activity') {
|
||||
setActivities(prev => [data.activity, ...prev].slice(0, 50));
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync session joining
|
||||
useEffect(() => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN && activeSessionId) {
|
||||
socketRef.current.send(JSON.stringify({
|
||||
type: 'join',
|
||||
sessionId: activeSessionId,
|
||||
user: { ...currentUser, color: stringToColor(currentUser.name) }
|
||||
}));
|
||||
}
|
||||
}, [activeSessionId, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/brainstorm/sessions')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setSessions(data);
|
||||
// Check for initial session from URL parameter (passed via window by App.tsx)
|
||||
const initialId = (window as any).initialSessionId;
|
||||
if (initialId && data.find((s: any) => s.id === initialId)) {
|
||||
setActiveSessionId(initialId);
|
||||
delete (window as any).initialSessionId;
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Failed to load sessions", err));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSessionId) {
|
||||
fetch(`/api/brainstorm/${activeSessionId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.ideas) {
|
||||
setIdeas(prev => {
|
||||
const filtered = prev.filter(i => i.sessionId !== activeSessionId);
|
||||
return [...filtered, ...data.ideas];
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Failed to load ideas", err));
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
const activeSession = useMemo(() =>
|
||||
sessions.find(s => s.id === activeSessionId),
|
||||
[activeSessionId, sessions]);
|
||||
|
||||
const activeIdeas = useMemo(() =>
|
||||
ideas.filter(i => i.sessionId === activeSessionId),
|
||||
[activeSessionId, ideas]);
|
||||
|
||||
const selectedIdea = useMemo(() =>
|
||||
ideas.find(i => i.id === selectedIdeaId),
|
||||
[selectedIdeaId, ideas]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRemoteStart = (e: any) => {
|
||||
if (e.detail?.seed) {
|
||||
handleStartBrainstorm(e.detail.seed, e.detail.sourceNoteId);
|
||||
}
|
||||
};
|
||||
window.addEventListener('start-brainstorm', handleRemoteStart);
|
||||
return () => window.removeEventListener('start-brainstorm', handleRemoteStart);
|
||||
}, [notes]);
|
||||
|
||||
const handleStartBrainstorm = async (seed: string, sourceNoteId?: string) => {
|
||||
if (!seed.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1. Create session on backend
|
||||
const sessionRes = await fetch('/api/brainstorm/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
seedIdea: seed,
|
||||
sourceNoteId
|
||||
})
|
||||
});
|
||||
const session = await sessionRes.json();
|
||||
if (!sessionRes.ok) throw new Error(session.error || "Failed to create session");
|
||||
|
||||
setSessions(prev => [session, ...prev]);
|
||||
setActiveSessionId(session.id);
|
||||
setSeedInput('');
|
||||
|
||||
// 2. Generate waves in frontend concurrently
|
||||
const contextSummaries = notes.slice(0, 5).map(n => n.title).join(', ');
|
||||
|
||||
const wavePromises = [1, 2, 3].map(async (num) => {
|
||||
try {
|
||||
const generated = await generateBrainstormWave(seed, num, contextSummaries);
|
||||
return generated.map(g => ({
|
||||
...g,
|
||||
waveNumber: num
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(`Wave ${num} failed`, e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const wavesResults = await Promise.all(wavePromises);
|
||||
const allNewIdeas = wavesResults.flat();
|
||||
|
||||
if (allNewIdeas.length === 0) {
|
||||
throw new Error("No ideas were generated. Gemini might be shy today.");
|
||||
}
|
||||
|
||||
// 3. Save ideas to backend
|
||||
const ideasRes = await fetch(`/api/brainstorm/${session.id}/ideas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ideas: allNewIdeas })
|
||||
});
|
||||
const savedIdeas = await ideasRes.json();
|
||||
setIdeas(prev => [...prev, ...savedIdeas]);
|
||||
|
||||
addActivity(`Generated ${savedIdeas.length} ideas for Wave ${allNewIdeas[0]?.waveNumber || ''}`);
|
||||
|
||||
// Notify others
|
||||
savedIdeas.forEach((idea: any) => {
|
||||
socketRef.current?.send(JSON.stringify({ type: 'idea_added', idea }));
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Brainstorm failed:", err);
|
||||
setError(err.message || "An unexpected error occurred while brainstorming.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateIdea = async (ideaId: string, updates: Partial<BrainstormIdea>) => {
|
||||
try {
|
||||
const res = await fetch(`/api/brainstorm/ideas/${ideaId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
const updated = await res.json();
|
||||
setIdeas(prev => prev.map(i => i.id === ideaId ? updated : i));
|
||||
|
||||
// Notify others
|
||||
socketRef.current?.send(JSON.stringify({ type: 'idea_updated', idea: updated }));
|
||||
} catch (err) {
|
||||
console.error("Update failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeepenIdea = async (idea: BrainstormIdea) => {
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const generated = await generateExpansion(idea.title, idea.description);
|
||||
const newIdeasData = generated.map(g => ({
|
||||
...g,
|
||||
waveNumber: Math.min(idea.waveNumber + 1, 3),
|
||||
parentIdeaId: idea.id
|
||||
}));
|
||||
|
||||
const res = await fetch(`/api/brainstorm/${idea.sessionId}/ideas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ideas: newIdeasData })
|
||||
});
|
||||
const savedIdeas = await res.json();
|
||||
setIdeas(prev => [...prev, ...savedIdeas]);
|
||||
addActivity(`Expanded idea: ${idea.title}`);
|
||||
|
||||
// Notify others
|
||||
savedIdeas.forEach((i: any) => {
|
||||
socketRef.current?.send(JSON.stringify({ type: 'idea_added', idea: i }));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Deepen failed", err);
|
||||
setError("Failed to expand this idea.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissIdea = (ideaId: string) => {
|
||||
updateIdea(ideaId, { status: 'dismissed' });
|
||||
setSelectedIdeaId(null);
|
||||
};
|
||||
|
||||
const handleConvertToNote = (idea: BrainstormIdea) => {
|
||||
updateIdea(idea.id, { status: 'converted' });
|
||||
onConvertNote(idea);
|
||||
};
|
||||
|
||||
const handleManualAdd = async (title: string, parentId?: string) => {
|
||||
if (!title.trim() || !activeSessionId) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
let finalParentId = parentId;
|
||||
let waveNumber = 1;
|
||||
|
||||
if (parentId) {
|
||||
const p = ideas.find(i => i.id === parentId);
|
||||
if (p) waveNumber = Math.min(p.waveNumber + 1, 3);
|
||||
} else if (activeIdeas.length > 0) {
|
||||
// Semantic auto-placement if no parent is specified
|
||||
try {
|
||||
const newEmbedding = await getEmbedding(title);
|
||||
let bestSim = -1;
|
||||
let bestParent: BrainstormIdea | null = null;
|
||||
|
||||
for (const idea of activeIdeas) {
|
||||
const ideaEmbedding = await getEmbedding(idea.title + " " + idea.description);
|
||||
const sim = cosineSimilarity(newEmbedding, ideaEmbedding);
|
||||
if (sim > bestSim) {
|
||||
bestSim = sim;
|
||||
bestParent = idea;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestParent && bestSim > 0.7) {
|
||||
finalParentId = bestParent.id;
|
||||
waveNumber = Math.min(bestParent.waveNumber + 1, 3);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Semantic placement failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
const newIdeaData = [{
|
||||
title: title,
|
||||
description: "",
|
||||
waveNumber: waveNumber,
|
||||
connectionToSeed: finalParentId
|
||||
? `Manual addition (auto-linked)`
|
||||
: "Manual addition to root",
|
||||
noveltyScore: 5,
|
||||
parentIdeaId: finalParentId
|
||||
}];
|
||||
|
||||
const res = await fetch(`/api/brainstorm/${activeSessionId}/ideas`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ideas: newIdeaData })
|
||||
});
|
||||
const saved = await res.json();
|
||||
setIdeas(prev => [...prev, ...saved]);
|
||||
|
||||
addActivity(`Manually added idea: ${title}`);
|
||||
|
||||
// Notify
|
||||
saved.forEach((i: any) => socketRef.current?.send(JSON.stringify({ type: 'idea_added', idea: i })));
|
||||
|
||||
setEditingNodeId(null);
|
||||
setManualTitle('');
|
||||
} catch (err) {
|
||||
console.error("Manual add failed", err);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvite = () => {
|
||||
if (!activeSessionId) return;
|
||||
const shareUrl = `${window.location.origin}${window.location.pathname}?session=${activeSessionId}`;
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
setShareStatus('copied');
|
||||
addActivity(`Invitation link copied to clipboard`);
|
||||
setTimeout(() => setShareStatus('idle'), 2000);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!activeSession) return;
|
||||
|
||||
let markdown = `# Brainstorm : ${activeSession.seedIdea}\n\n`;
|
||||
markdown += `Date : ${new Date(activeSession.createdAt).toLocaleDateString()}\n\n`;
|
||||
|
||||
[1, 2, 3].forEach(waveNum => {
|
||||
const waveIdeas = activeIdeas.filter(i => i.waveNumber === waveNum);
|
||||
if (waveIdeas.length > 0) {
|
||||
markdown += `## Vague ${waveNum}\n\n`;
|
||||
waveIdeas.forEach(idea => {
|
||||
markdown += `### ${idea.title}\n`;
|
||||
markdown += `${idea.description}\n`;
|
||||
markdown += `*Score de nouveauté : ${idea.noveltyScore}/10*\n`;
|
||||
markdown += `*Connexion : ${idea.connectionToSeed}*\n\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onConvertNote({
|
||||
id: uuidv4(),
|
||||
title: `Brainstorm Export: ${activeSession.seedIdea}`,
|
||||
description: markdown,
|
||||
sessionId: activeSession.id,
|
||||
waveNumber: 0,
|
||||
connectionToSeed: "Export",
|
||||
noveltyScore: 10,
|
||||
status: 'converted'
|
||||
});
|
||||
|
||||
addActivity(`Session exported to notes`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#F8F7F2] dark:bg-[#0A0A0A] overflow-hidden">
|
||||
{/* Header / Start area */}
|
||||
<div className="p-12 border-b border-border/20 backdrop-blur-md bg-white/20 dark:bg-dark-paper/20 z-10 relative overflow-hidden">
|
||||
{/* Architectural Grid Background */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05]"
|
||||
style={{ backgroundImage: 'linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
|
||||
|
||||
<div className="max-w-4xl mx-auto relative">
|
||||
<div className="flex items-center gap-5 mb-8">
|
||||
<motion.div
|
||||
animate={{ rotate: isGenerating ? 360 : 0 }}
|
||||
transition={{ repeat: isGenerating ? Infinity : 0, duration: 20, ease: "linear" }}
|
||||
className="w-14 h-14 rounded-2xl bg-ochre shadow-[0_0_20px_rgba(212,163,115,0.2)] flex items-center justify-center text-paper"
|
||||
>
|
||||
<Wind size={28} />
|
||||
</motion.div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-4xl font-serif font-medium text-ink dark:text-dark-ink tracking-tight">Waves of Thought</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="w-8 h-px bg-ochre/40" />
|
||||
<p className="text-[10px] text-concrete tracking-[0.3em] uppercase font-bold">Unfold dimensions of potentiality</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeSession && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 border border-border rounded-xl text-xs font-bold uppercase tracking-widest text-concrete hover:text-ochre transition-all shadow-sm"
|
||||
title="Export to Note"
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowActivity(!showActivity)}
|
||||
className={`flex items-center gap-2 px-4 py-2 border border-border rounded-xl text-xs font-bold uppercase tracking-widest transition-all shadow-sm ${showActivity ? 'bg-ink text-paper' : 'bg-white dark:bg-white/5 text-concrete hover:text-ink'}`}
|
||||
title="Show Activity"
|
||||
>
|
||||
<Activity size={14} />
|
||||
<span className="hidden sm:inline">Activity</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInvite}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 border border-border rounded-xl text-xs font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all shadow-sm"
|
||||
>
|
||||
{shareStatus === 'copied' ? <Check size={14} className="text-emerald-500" /> : <Share2 size={14} />}
|
||||
{shareStatus === 'copied' ? 'Link Copied' : 'Invite'}
|
||||
</button>
|
||||
<div className="flex items-center -space-x-2 mr-2">
|
||||
{collaborators.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="relative group/avatar"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full border-2 border-paper dark:border-dark-paper flex items-center justify-center text-[10px] font-bold text-white shadow-sm cursor-help relative z-10"
|
||||
style={{ backgroundColor: user.color || '#999' }}
|
||||
>
|
||||
{getInitials(user.name)}
|
||||
</div>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-ink text-paper text-[10px] rounded font-bold whitespace-nowrap opacity-0 group-hover/avatar:opacity-100 pointer-events-none transition-opacity z-20">
|
||||
{user.name}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-ink" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 px-3 py-2 bg-emerald-500/10 rounded-full mr-2">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<Users size={14} className="text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-ochre/20 to-blueprint/20 rounded-[28px] blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-700" />
|
||||
<input
|
||||
type="text"
|
||||
value={seedInput}
|
||||
onChange={(e) => setSeedInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStartBrainstorm(seedInput)}
|
||||
placeholder="Enter a concept to unfold..."
|
||||
className={`w-full relative bg-white dark:bg-[#1A1A1A] border-2 rounded-2xl px-8 py-7 pr-20 outline-none transition-all text-2xl font-serif italic text-ink dark:text-dark-ink shadow-sm group-hover:shadow-md
|
||||
${error ? 'border-rose-400 focus:ring-rose-100 shadow-rose-100' : 'border-border/40 focus:border-ochre/40 focus:ring-4 focus:ring-ochre/5'}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleStartBrainstorm(seedInput)}
|
||||
disabled={isGenerating || !seedInput.trim()}
|
||||
className="absolute right-4 top-4 bottom-4 px-6 bg-ink dark:bg-ochre text-paper rounded-xl disabled:opacity-50 transition-all hover:scale-[1.02] active:scale-[0.98] flex items-center justify-center gap-2 min-w-[70px] shadow-lg"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="w-6 h-6 border-3 border-paper/30 border-t-paper rounded-full animate-spin" />
|
||||
) : (
|
||||
<Plus size={24} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mt-6 p-5 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 rounded-2xl flex items-start gap-4 text-rose-600 dark:text-rose-400 text-sm overflow-hidden shadow-sm"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full bg-rose-100 dark:bg-rose-500/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<div className="w-2 h-2 rounded-full bg-rose-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold uppercase tracking-wider text-[10px] mb-1">Obstruction detected</p>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{isGenerating && !error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mt-6 flex items-center gap-4 text-ochre/80 italic font-serif"
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{[0.2, 0.4, 0.6].map((d, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, delay: d }}
|
||||
className="w-1.5 h-1.5 rounded-full bg-ochre"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-base tracking-tight">Gemini is harvesting seeds of thought from the digital ether...</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{/* Main Canvas Area */}
|
||||
<div className="flex-1 relative bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#ffffff10_1px,transparent_1px)] [background-size:20px_20px]">
|
||||
{activeSession ? (
|
||||
<div onClick={() => setSelectedIdeaId(null)} className="w-full h-full">
|
||||
<WaveCanvas
|
||||
session={activeSession}
|
||||
ideas={activeIdeas}
|
||||
onNodeSelect={(id) => {
|
||||
setSelectedIdeaId(id);
|
||||
}}
|
||||
onPositionUpdate={(id, pos) => updateIdea(id, { position: pos })}
|
||||
onAddChild={(id) => {
|
||||
setSelectedIdeaId(id);
|
||||
setEditingNodeId(id);
|
||||
}}
|
||||
onManualSubmit={handleManualAdd}
|
||||
onManualCancel={() => setEditingNodeId(null)}
|
||||
editingNodeId={editingNodeId}
|
||||
selectedNodeId={selectedIdeaId}
|
||||
relatedNotes={notes}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20 flex-col gap-6">
|
||||
<Wind size={120} strokeWidth={1} className="text-concrete animate-pulse" />
|
||||
<p className="text-xl font-serif italic text-concrete">The canvas is waiting for your spark...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating UI overlays */}
|
||||
<AnimatePresence>
|
||||
{activeSession && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute bottom-6 left-6 flex gap-2"
|
||||
>
|
||||
<div className="px-4 py-2 bg-paper/80 dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-400 shadow-[0_0_8px_rgba(251,146,60,0.6)]" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 1</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400 shadow-[0_0_8px_rgba(96,165,250,0.6)]" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 2</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-violet-400 shadow-[0_0_8px_rgba(167,139,250,0.6)]" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 3</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setEditingNodeId('new')}
|
||||
className="px-6 py-3 bg-paper dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-concrete hover:bg-ink hover:text-paper transition-all"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Manual Idea
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Activity Sidebar */}
|
||||
<AnimatePresence>
|
||||
{showActivity && (
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed right-0 top-0 h-full w-80 bg-paper dark:bg-dark-paper border-l border-border shadow-2xl z-[70] flex flex-col"
|
||||
>
|
||||
<div className="p-6 border-b border-border flex items-center justify-between bg-ink text-paper">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity size={18} />
|
||||
<h3 className="font-bold uppercase tracking-widest text-xs">Flux d'activité</h3>
|
||||
</div>
|
||||
<button onClick={() => setShowActivity(false)} className="p-1 hover:bg-white/10 rounded-lg">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{activities.length === 0 ? (
|
||||
<p className="text-xs text-concrete text-center italic mt-10">Aucune activité pour le moment</p>
|
||||
) : (
|
||||
activities.map((act) => (
|
||||
<motion.div
|
||||
key={act.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="p-3 bg-white dark:bg-white/5 rounded-xl border border-border/50 relative overflow-hidden group"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-ochre/40" />
|
||||
<p className="text-[11px] font-medium text-ink dark:text-dark-ink">{act.message}</p>
|
||||
<span className="text-[9px] text-concrete font-bold mt-1 block">{act.timestamp}</span>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Right Sidebar Detail Panel */}
|
||||
<AnimatePresence>
|
||||
{selectedIdea && (
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
className="w-[400px] border-l border-border bg-paper dark:bg-dark-paper flex flex-col z-20 shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
|
||||
>
|
||||
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border
|
||||
${selectedIdea.waveNumber === 1 ? 'border-orange-200 bg-orange-50 text-orange-600' :
|
||||
selectedIdea.waveNumber === 2 ? 'border-blue-200 bg-blue-50 text-blue-600' :
|
||||
'border-violet-200 bg-violet-50 text-violet-600'}`}>
|
||||
Vague {selectedIdea.waveNumber}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIdea.status === 'converted' && (
|
||||
<span className="text-[10px] font-bold text-emerald-500 uppercase tracking-widest bg-emerald-500/10 px-2 py-1 rounded-full">Note Created</span>
|
||||
)}
|
||||
<button onClick={() => setSelectedIdeaId(null)} className="p-2 hover:bg-ink/5 rounded-full transition-colors">
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-serif font-medium text-ink dark:text-dark-ink mb-2">{selectedIdea.title}</h2>
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap size={14} className="text-ochre" />
|
||||
<span className="text-xs font-bold text-concrete">Novelty: {selectedIdea.noveltyScore}/10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-ink/80 dark:text-dark-ink/80 leading-relaxed font-light mb-10 text-lg">
|
||||
{selectedIdea.description}
|
||||
</p>
|
||||
|
||||
<div className="p-6 bg-slate-50 dark:bg-white/5 rounded-2xl border border-border/40 mb-10">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-widest text-concrete mb-3">Origin connection</h4>
|
||||
<p className="text-sm italic text-muted-ink leading-relaxed">
|
||||
"{selectedIdea.connectionToSeed}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedIdea.relatedNoteIds && selectedIdea.relatedNoteIds.length > 0 && (
|
||||
<div className="space-y-4 mb-10">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-widest text-concrete px-1">Semantic Context</h4>
|
||||
{selectedIdea.relatedNoteIds.map(noteId => {
|
||||
const note = notes.find(n => n.id === noteId);
|
||||
return note ? (
|
||||
<div key={noteId} className="p-4 rounded-xl border border-border bg-white dark:bg-white/5 hover:border-ink/20 transition-all cursor-pointer group">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-ink dark:text-dark-ink truncate">{note.title}</h5>
|
||||
<ArrowRight size={14} className="text-concrete group-hover:text-ink transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => handleDeepenIdea(selectedIdea)}
|
||||
disabled={isGenerating}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-ochre/40 hover:bg-ochre/5 transition-all group disabled:opacity-50"
|
||||
>
|
||||
<Wind size={24} className="text-concrete group-hover:text-ochre mb-2" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink text-center">AI Expand</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingNodeId(selectedIdea.id)}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-ink/40 hover:bg-ink/5 transition-all group disabled:opacity-50"
|
||||
>
|
||||
<PlusCircle size={24} className="text-concrete group-hover:text-ink mb-2" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink text-center">Add Child</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleConvertToNote(selectedIdea)}
|
||||
disabled={selectedIdea.status === 'converted'}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-blueprint/40 hover:bg-blueprint/5 transition-all group disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
<FileText size={24} className="text-concrete group-hover:text-blueprint mb-2" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink text-center">Extract Note</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleDismissIdea(selectedIdea.id)}
|
||||
className="w-full py-4 text-[10px] font-bold uppercase tracking-[0.2em] text-concrete hover:text-rose-500 hover:bg-rose-500/5 rounded-xl transition-all border border-transparent hover:border-rose-500/10"
|
||||
>
|
||||
Not pertinent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* History Rail */}
|
||||
<div className="w-16 border-l border-border flex flex-col items-center py-6 gap-6 bg-paper dark:bg-dark-paper z-10">
|
||||
<History size={18} className="text-concrete" />
|
||||
<div className="w-px flex-1 bg-border/40" />
|
||||
<div className="flex flex-col gap-3 overflow-y-auto px-2 custom-scrollbar">
|
||||
{sessions.map(session => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => setActiveSessionId(session.id)}
|
||||
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0
|
||||
${activeSessionId === session.id ? 'bg-ink text-paper scale-110 shadow-lg' : 'bg-paper dark:bg-white/10 text-concrete hover:bg-black/5 hover:text-ink'}`}
|
||||
title={session.seedIdea}
|
||||
>
|
||||
{session.seedIdea.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-px h-12 bg-border/40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,348 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { BrainstormSession, BrainstormIdea, Note } from '../../types';
|
||||
|
||||
interface WaveCanvasProps {
|
||||
session: BrainstormSession;
|
||||
ideas: BrainstormIdea[];
|
||||
onNodeSelect: (id: string) => void;
|
||||
onPositionUpdate: (id: string, pos: { x: number; y: number }) => void;
|
||||
onAddChild: (id: string) => void;
|
||||
onManualSubmit: (title: string, parentId?: string) => void;
|
||||
onManualCancel: () => void;
|
||||
editingNodeId: string | null;
|
||||
selectedNodeId: string | null;
|
||||
relatedNotes: Note[];
|
||||
}
|
||||
|
||||
export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
session,
|
||||
ideas,
|
||||
onNodeSelect,
|
||||
onPositionUpdate,
|
||||
onAddChild,
|
||||
onManualSubmit,
|
||||
onManualCancel,
|
||||
editingNodeId,
|
||||
selectedNodeId,
|
||||
relatedNotes
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (editingNodeId && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [editingNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current) return;
|
||||
|
||||
const width = containerRef.current.clientWidth;
|
||||
const height = containerRef.current.clientHeight;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const g = svg.append("g");
|
||||
|
||||
// Zoom behavior
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 5])
|
||||
.on("zoom", (event) => {
|
||||
g.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
// Initial transform to center
|
||||
svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8));
|
||||
|
||||
// Data structures for d3
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
type: 'root' | 'idea' | 'note';
|
||||
wave?: number;
|
||||
title: string;
|
||||
color: string;
|
||||
radius: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
||||
source: string | D3Node;
|
||||
target: string | D3Node;
|
||||
type: 'wave' | 'context' | 'parent';
|
||||
}
|
||||
|
||||
const nodes: D3Node[] = [];
|
||||
const links: D3Link[] = [];
|
||||
|
||||
// Root node
|
||||
const rootNode: D3Node = {
|
||||
id: 'root',
|
||||
type: 'root',
|
||||
title: session.seedIdea,
|
||||
color: '#141414',
|
||||
radius: 40,
|
||||
fx: 0,
|
||||
fy: 0
|
||||
};
|
||||
nodes.push(rootNode);
|
||||
|
||||
// Idea nodes
|
||||
const colors = {
|
||||
1: '#fb923c', // orange
|
||||
2: '#60a5fa', // blue
|
||||
3: '#a78bfa' // violet
|
||||
};
|
||||
|
||||
ideas.forEach(idea => {
|
||||
nodes.push({
|
||||
id: idea.id,
|
||||
type: 'idea',
|
||||
wave: idea.waveNumber,
|
||||
title: idea.title,
|
||||
color: colors[idea.waveNumber as 1|2|3] || '#94a3b8',
|
||||
radius: 28,
|
||||
status: idea.status,
|
||||
x: idea.position?.x,
|
||||
y: idea.position?.y
|
||||
});
|
||||
|
||||
if (idea.parentIdeaId) {
|
||||
links.push({
|
||||
source: idea.parentIdeaId,
|
||||
target: idea.id,
|
||||
type: 'parent'
|
||||
});
|
||||
} else {
|
||||
links.push({
|
||||
source: 'root',
|
||||
target: idea.id,
|
||||
type: 'wave'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Radial layout forces
|
||||
const simulation = d3.forceSimulation<D3Node>(nodes)
|
||||
.force("link", d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(d => {
|
||||
if (d.type === 'wave') {
|
||||
const targetNode = nodes.find(n => n.id === (typeof d.target === 'string' ? d.target : (d.target as any).id));
|
||||
return (targetNode?.wave || 1) * 200;
|
||||
}
|
||||
if (d.type === 'parent') return 180;
|
||||
return 100;
|
||||
}))
|
||||
.force("charge", d3.forceManyBody().strength(-800))
|
||||
.force("radial", d3.forceRadial<D3Node>(d => {
|
||||
if (d.type === 'root') return 0;
|
||||
if (d.id.includes('-')) return (d.wave || 1) * 200 + 100; // Deepened ideas push out
|
||||
return (d.wave || 1) * 200;
|
||||
}, 0, 0).strength(0.8))
|
||||
.force("collision", d3.forceCollide<D3Node>().radius(d => d.radius + 30));
|
||||
|
||||
// Drawing rings
|
||||
const ringRadii = [200, 400, 600];
|
||||
g.selectAll(".ring")
|
||||
.data(ringRadii)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("class", "ring")
|
||||
.attr("r", d => d)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#e2e8f0")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-dasharray", "4,4")
|
||||
.style("opacity", 0.5);
|
||||
|
||||
// Links
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("stroke", d => d.type === 'wave' ? "#cbd5e1" : d.type === 'parent' ? "#fde047" : "#94a3b8")
|
||||
.attr("stroke-width", d => d.type === 'wave' ? 1.5 : 2)
|
||||
.attr("stroke-dasharray", d => d.type === 'parent' ? "none" : "4,4");
|
||||
|
||||
// Nodes
|
||||
const node = g.append("g")
|
||||
.selectAll(".node")
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "node")
|
||||
.style("opacity", d => d.status === 'dismissed' ? 0.4 : 1)
|
||||
.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
if (d.type === 'idea') onNodeSelect(d.id);
|
||||
})
|
||||
.on("dblclick", (event, d) => {
|
||||
event.stopPropagation();
|
||||
if (d.type === 'idea') {
|
||||
onNodeSelect(d.id);
|
||||
onAddChild(d.id);
|
||||
}
|
||||
})
|
||||
.call(d3.drag<SVGGElement, D3Node>()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended) as any);
|
||||
|
||||
node.append("circle")
|
||||
.attr("r", d => d.radius)
|
||||
.attr("fill", d => d.status === 'converted' ? '#ecfdf5' : (d.type === 'root' ? '#141414' : '#fff'))
|
||||
.attr("stroke", d => d.status === 'converted' ? '#10b981' : d.color)
|
||||
.attr("stroke-width", d => d.id === selectedNodeId ? 4 : 2)
|
||||
.attr("class", "cursor-pointer transition-all hover:scale-110")
|
||||
.style("filter", d => d.id === selectedNodeId ? `drop-shadow(0 0 12px ${d.color}cc)` : "none");
|
||||
|
||||
// Plus icon for selected node
|
||||
node.filter(d => d.id === selectedNodeId && d.type === 'idea')
|
||||
.append("circle")
|
||||
.attr("r", 10)
|
||||
.attr("cx", 20)
|
||||
.attr("cy", -20)
|
||||
.attr("fill", "#141414")
|
||||
.attr("stroke", "#fff")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("class", "cursor-pointer")
|
||||
.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
onAddChild(d.id);
|
||||
});
|
||||
|
||||
node.filter(d => d.id === selectedNodeId && d.type === 'idea')
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", -17)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#fff")
|
||||
.attr("class", "text-[14px] font-bold pointer-events-none")
|
||||
.text("+");
|
||||
|
||||
// ForeignObject for inline input
|
||||
const editGroup = node.filter(d => d.id === editingNodeId && d.type === 'idea')
|
||||
.append("foreignObject")
|
||||
.attr("width", 200)
|
||||
.attr("height", 80)
|
||||
.attr("x", -100)
|
||||
.attr("y", 40)
|
||||
.append("xhtml:div")
|
||||
.attr("class", "bg-paper dark:bg-black p-2 border border-border rounded-xl shadow-2xl");
|
||||
|
||||
editGroup.append("input")
|
||||
.attr("type", "text")
|
||||
.attr("placeholder", "Nouvelle idée...")
|
||||
.attr("class", "w-full bg-white dark:bg-[#1A1A1A] border-none outline-none text-xs font-bold uppercase tracking-tight p-2 rounded-lg")
|
||||
.on("keydown", (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onManualSubmit(event.target.value, editingNodeId!);
|
||||
event.target.value = '';
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
onManualCancel();
|
||||
}
|
||||
})
|
||||
.on("blur", () => {
|
||||
// Optional: onManualCancel();
|
||||
});
|
||||
|
||||
// Special case for root addition input (if editingNodeId is 'new')
|
||||
if (editingNodeId === 'new') {
|
||||
g.append("foreignObject")
|
||||
.attr("width", 200)
|
||||
.attr("height", 80)
|
||||
.attr("x", -100)
|
||||
.attr("y", -120) // Floating above center
|
||||
.append("xhtml:div")
|
||||
.attr("class", "bg-paper dark:bg-black p-2 border border-border rounded-xl shadow-2xl animate-bounce")
|
||||
.append("input")
|
||||
.attr("type", "text")
|
||||
.attr("autoFocus", "true")
|
||||
.attr("placeholder", "Idée libre...")
|
||||
.attr("class", "w-full bg-white dark:bg-[#1A1A1A] border-none outline-none text-xs font-bold uppercase tracking-tight p-2 rounded-lg")
|
||||
.on("keydown", (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onManualSubmit(event.target.value);
|
||||
event.target.value = '';
|
||||
}
|
||||
if (event.key === 'Escape') onManualCancel();
|
||||
});
|
||||
}
|
||||
|
||||
// State indicators (converted)
|
||||
node.filter(d => d.status === 'converted')
|
||||
.append("path")
|
||||
.attr("d", d3.symbol().type(d3.symbolCircle).size(150))
|
||||
.attr("fill", "#10b981");
|
||||
|
||||
// Icons/Text in nodes
|
||||
node.append("text")
|
||||
.attr("dy", d => d.type === 'root' ? ".35em" : d.radius + 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", d => d.type === 'root' ? "#fff" : (d.status === 'dismissed' ? "#94a3b8" : "#141414"))
|
||||
.attr("class", d => d.type === 'root' ? "text-[10px] font-bold pointer-events-none tracking-widest" : "text-[11px] font-bold uppercase tracking-tight pointer-events-none")
|
||||
.text(d => d.type === 'root' ? "SEED" : d.title.length > 18 ? d.title.substring(0, 18) + "..." : d.title);
|
||||
|
||||
if (rootNode) {
|
||||
g.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dy", 80)
|
||||
.attr("class", "text-2xl font-serif italic fill-ink dark:fill-dark-ink pointer-events-none shadow-sm")
|
||||
.text(session.seedIdea);
|
||||
}
|
||||
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => (d.source as any).x)
|
||||
.attr("y1", d => (d.source as any).y)
|
||||
.attr("x2", d => (d.target as any).x)
|
||||
.attr("y2", d => (d.target as any).y);
|
||||
|
||||
node
|
||||
.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
function dragstarted(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event: any, d: D3Node) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
if (d.type === 'idea') {
|
||||
onPositionUpdate(d.id, { x: event.x, y: event.y });
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
simulation.stop();
|
||||
};
|
||||
}, [session, ideas, selectedNodeId, editingNodeId, onNodeSelect]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full relative cursor-grab active:cursor-grabbing">
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
<div className="absolute top-6 left-6 pointer-events-none">
|
||||
<p className="text-[10px] font-bold tracking-[0.3em] uppercase text-concrete opacity-40">Spatial Exploration Mode</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Check,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
import { Carnet } from '../types';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
interface HierarchicalCarnetSelectorProps {
|
||||
carnets: Carnet[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const HierarchicalCarnetSelector: React.FC<HierarchicalCarnetSelectorProps> = ({
|
||||
carnets,
|
||||
selectedId,
|
||||
onSelect,
|
||||
className = "",
|
||||
placeholder = "Sélectionner un carnet..."
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(['1', '4'])); // Default expand some
|
||||
|
||||
const selectedCarnet = carnets.find(c => c.id === selectedId);
|
||||
|
||||
// Derive the path for display
|
||||
const path = useMemo(() => {
|
||||
if (!selectedCarnet) return [];
|
||||
const trail: Carnet[] = [];
|
||||
let current = selectedCarnet;
|
||||
while (current) {
|
||||
trail.unshift(current);
|
||||
if (!current.parentId) break;
|
||||
const parent = carnets.find(c => c.id === current.parentId);
|
||||
if (!parent) break;
|
||||
current = parent;
|
||||
}
|
||||
return trail;
|
||||
}, [selectedCarnet, carnets]);
|
||||
|
||||
const toggleExpand = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
const newExpanded = new Set(expandedIds);
|
||||
if (newExpanded.has(id)) {
|
||||
newExpanded.delete(id);
|
||||
} else {
|
||||
newExpanded.add(id);
|
||||
}
|
||||
setExpandedIds(newExpanded);
|
||||
};
|
||||
|
||||
const filteredCarnets = useMemo(() => {
|
||||
if (!searchQuery) return carnets;
|
||||
return carnets.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}, [carnets, searchQuery]);
|
||||
|
||||
const renderTree = (parentId?: string, level = 0) => {
|
||||
const children = carnets.filter(c => c.parentId === parentId);
|
||||
if (children.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={level > 0 ? "ml-4 border-l border-border/40 pl-2" : ""}>
|
||||
{children.map(carnet => {
|
||||
const isExpanded = expandedIds.has(carnet.id) || searchQuery.length > 0;
|
||||
const hasChildren = carnets.some(c => c.parentId === carnet.id);
|
||||
const isSelected = selectedId === carnet.id;
|
||||
|
||||
// If searching and this carnet doesn't match AND none of its children match, skip it
|
||||
if (searchQuery && !carnet.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
const hasMatchingChild = (id: string): boolean => {
|
||||
const childrenOfId = carnets.filter(c => c.parentId === id);
|
||||
return childrenOfId.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()) || hasMatchingChild(c.id));
|
||||
};
|
||||
if (!hasMatchingChild(carnet.id)) return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={carnet.id} className="select-none">
|
||||
<div
|
||||
onClick={() => {
|
||||
onSelect(carnet.id);
|
||||
if (!searchQuery) setIsOpen(false);
|
||||
}}
|
||||
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
|
||||
${isSelected ? 'bg-blueprint/10 text-blueprint font-bold' : 'hover:bg-slate-50 dark:hover:bg-white/5 text-ink'}`}
|
||||
>
|
||||
<div className="w-4 flex items-center justify-center">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => toggleExpand(e, carnet.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`p-1 rounded ${isSelected ? 'bg-blueprint/20' : 'bg-slate-100 dark:bg-white/5 group-hover:bg-white/40'}`}>
|
||||
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||
</div>
|
||||
|
||||
<span className="text-[13px] truncate flex-1">{carnet.name}</span>
|
||||
|
||||
{isSelected && <Check size={14} className="opacity-60" />}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{renderTree(carnet.id, level + 1)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full bg-slate-50 dark:bg-black/20 border border-border/80 rounded-xl px-4 py-4 text-sm outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all cursor-pointer text-ink flex items-center gap-3"
|
||||
>
|
||||
<Folder size={16} className="text-blueprint/60 shrink-0" />
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
{path.length > 0 ? (
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
{path.map((item, i) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{i > 0 && <span className="text-concrete/40 text-[10px]">/</span>}
|
||||
<span className={`truncate ${i === path.length - 1 ? 'font-bold' : 'text-concrete'}`}>
|
||||
{item.name}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-concrete italic">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown size={14} className={`transition-transform duration-300 text-concrete shrink-0 ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[60]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
className="absolute z-[70] mt-2 w-full bg-white dark:bg-dark-paper border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col min-w-[280px]"
|
||||
>
|
||||
<div className="p-3 border-b border-border/40 bg-slate-50/50">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Filtrer les carnets..."
|
||||
className="w-full bg-white border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-blueprint transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto custom-scrollbar p-2">
|
||||
{renderTree(undefined)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-border/40 bg-slate-50/30 flex justify-between items-center px-4">
|
||||
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">
|
||||
Structure des carnets
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-[10px] font-bold text-blueprint hover:underline"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
248
architectural-grid14/src/components/InsightsView.tsx
Normal file
248
architectural-grid14/src/components/InsightsView.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Network,
|
||||
Lightbulb,
|
||||
Layers,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
Trophy,
|
||||
Zap,
|
||||
Tag,
|
||||
Link as LinkIcon
|
||||
} from 'lucide-react';
|
||||
import { Note, NoteCluster, BridgeNote, ConnectionSuggestion } from '../types';
|
||||
import { runClustering, detectBridges, calculateCentroid } from '../services/clusteringService';
|
||||
import { nameCluster, suggestBridgeIdeas } from '../services/geminiService';
|
||||
import { NetworkGraph } from './NetworkGraph';
|
||||
|
||||
interface InsightsViewProps {
|
||||
notes: Note[];
|
||||
onUpdateNotes: (updatedNotes: Note[]) => void;
|
||||
onNoteSelect: (noteId: string) => void;
|
||||
}
|
||||
|
||||
export const InsightsView: React.FC<InsightsViewProps> = ({
|
||||
notes,
|
||||
onUpdateNotes,
|
||||
onNoteSelect
|
||||
}) => {
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [clusters, setClusters] = useState<NoteCluster[]>([]);
|
||||
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<ConnectionSuggestion[]>([]);
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null);
|
||||
|
||||
const performAnalysis = async () => {
|
||||
setIsCalculating(true);
|
||||
try {
|
||||
// 1. Run clustering
|
||||
const { clusters: newClusters } = runClustering(notes);
|
||||
|
||||
// 2. Name clusters (first 5 unique notes per cluster)
|
||||
const namedClusters = await Promise.all(newClusters.map(async (c) => {
|
||||
const clusterNoteSummaries = notes
|
||||
.filter(n => c.noteIds.includes(n.id))
|
||||
.slice(0, 5)
|
||||
.map(n => n.title);
|
||||
|
||||
const name = await nameCluster(clusterNoteSummaries);
|
||||
const centroid = calculateCentroid(c.noteIds, notes);
|
||||
|
||||
return { ...c, name, centroid };
|
||||
}));
|
||||
|
||||
// 3. Update notes with cluster IDs
|
||||
const updatedNotes = notes.map(n => {
|
||||
const cluster = namedClusters.find(c => c.noteIds.includes(n.id));
|
||||
return { ...n, clusterId: cluster?.id };
|
||||
});
|
||||
onUpdateNotes(updatedNotes);
|
||||
|
||||
// 4. Detect bridges
|
||||
const bridges = detectBridges(updatedNotes, namedClusters);
|
||||
|
||||
// 5. Build suggestions for isolated cluster pairs
|
||||
// For demo, we'll just pick a few interesting pairs
|
||||
const newSuggestions: ConnectionSuggestion[] = [];
|
||||
if (namedClusters.length >= 2) {
|
||||
// Find clusters with no mutual bridge notes or low connectivity
|
||||
for (let i = 0; i < Math.min(namedClusters.length, 3); i++) {
|
||||
for (let j = i + 1; j < Math.min(namedClusters.length, 3); j++) {
|
||||
const cA = namedClusters[i];
|
||||
const cB = namedClusters[j];
|
||||
|
||||
const cA_notes = updatedNotes.filter(n => cA.noteIds.includes(n.id)).map(n => n.title).join(', ');
|
||||
const cB_notes = updatedNotes.filter(n => cB.noteIds.includes(n.id)).map(n => n.title).join(', ');
|
||||
|
||||
const bridgeIdeas = await suggestBridgeIdeas(cA.name, cB.name, cA_notes, cB_notes);
|
||||
bridgeIdeas.forEach((idea, idx) => {
|
||||
newSuggestions.push({
|
||||
id: `suggestion-${i}-${j}-${idx}`,
|
||||
...idea,
|
||||
clusterAId: cA.id,
|
||||
clusterBId: cB.id
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setClusters(namedClusters);
|
||||
setBridgeNotes(bridges);
|
||||
setSuggestions(newSuggestions);
|
||||
} catch (error) {
|
||||
console.error("Analysis failed:", error);
|
||||
} finally {
|
||||
setIsCalculating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (notes.some(n => n.embedding) && clusters.length === 0) {
|
||||
performAnalysis();
|
||||
}
|
||||
}, [notes]);
|
||||
|
||||
const bridgeList = useMemo(() => {
|
||||
return bridgeNotes.map(b => {
|
||||
const note = notes.find(n => n.id === b.noteId);
|
||||
return { ...b, title: note?.title || 'Unknown Note' };
|
||||
});
|
||||
}, [bridgeNotes, notes]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-paper dark:bg-[#0D0D0D] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-8 border-b border-border/40 flex items-center justify-between backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center text-indigo-500">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Semantic Insights</h1>
|
||||
</div>
|
||||
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Discovering the hidden architecture of your knowledge</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={performAnalysis}
|
||||
disabled={isCalculating}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isCalculating ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
{isCalculating ? 'Mapping...' : 'Re-sync Network'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: Graph View */}
|
||||
<div className="flex-[1.5] p-6 relative">
|
||||
<NetworkGraph
|
||||
notes={notes}
|
||||
clusters={clusters}
|
||||
bridgeNotes={bridgeNotes}
|
||||
onNoteSelect={onNoteSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Insight Dashboard */}
|
||||
<div className="flex-1 border-l border-border/40 flex flex-col h-full bg-paper/50 dark:bg-black/10 backdrop-blur-sm overflow-hidden">
|
||||
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar space-y-12">
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-indigo-500 mb-2">
|
||||
<Layers size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Clusters</span>
|
||||
</div>
|
||||
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{clusters.length}</div>
|
||||
</div>
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-ochre mb-2">
|
||||
<Trophy size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Bridge Notes</span>
|
||||
</div>
|
||||
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bridge Notes Section */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6 px-1">
|
||||
<Zap size={16} className="text-ochre" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Powerful Bridge Notes</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{bridgeList.map(bridge => (
|
||||
<motion.div
|
||||
key={bridge.noteId}
|
||||
whileHover={{ x: 4 }}
|
||||
onClick={() => onNoteSelect(bridge.noteId)}
|
||||
className="p-4 rounded-xl bg-white dark:bg-white/5 border border-border hover:border-ochre/40 transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">{bridge.title}</h4>
|
||||
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
||||
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{bridge.connectedClusterIds.map(cid => {
|
||||
const c = clusters.find(cl => cl.id === cid);
|
||||
return (
|
||||
<div key={cid} className="flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c?.color }} />
|
||||
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{c?.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{bridgeList.length === 0 && !isCalculating && (
|
||||
<div className="text-xs text-concrete italic">No significant bridge notes found yet. Deepen your research to find new connections.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connection Suggestions */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6 px-1">
|
||||
<Lightbulb size={16} className="text-indigo-500" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Missing Links (AI Generated)</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{suggestions.map((s, idx) => (
|
||||
<div key={s.id} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 to-transparent border border-indigo-500/10 hover:border-indigo-500/30 transition-all">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex -space-x-2">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[10px] text-white">A</div>
|
||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
|
||||
</div>
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">Bridging {clusters.find(c => c.id === s.clusterAId)?.name} & {clusters.find(c => c.id === s.clusterBId)?.name}</span>
|
||||
</div>
|
||||
<h4 className="text-base font-serif font-medium text-ink dark:text-dark-ink mb-2">{s.title}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.description}</p>
|
||||
<div className="p-3 bg-white/40 dark:bg-white/5 rounded-xl border border-border/40 text-[10px] italic text-concrete flex gap-2">
|
||||
<Zap size={12} className="shrink-0" />
|
||||
<span>{s.reasoning}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isCalculating && (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
173
architectural-grid14/src/components/NetworkGraph.tsx
Normal file
173
architectural-grid14/src/components/NetworkGraph.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { Note, NoteCluster, BridgeNote } from '../types';
|
||||
|
||||
interface NetworkGraphProps {
|
||||
notes: Note[];
|
||||
clusters: NoteCluster[];
|
||||
bridgeNotes: BridgeNote[];
|
||||
onNoteSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export const NetworkGraph: React.FC<NetworkGraphProps> = ({
|
||||
notes,
|
||||
clusters,
|
||||
bridgeNotes,
|
||||
onNoteSelect
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current) return;
|
||||
|
||||
const width = containerRef.current.clientWidth;
|
||||
const height = containerRef.current.clientHeight;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const g = svg.append("g");
|
||||
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on("zoom", (event) => {
|
||||
g.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
|
||||
// Filter notes with embeddings and cluster assignments
|
||||
const visibleNotes = notes.filter(n => n.embedding && n.clusterId);
|
||||
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
title: string;
|
||||
clusterId: string;
|
||||
color: string;
|
||||
isBridge: boolean;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
||||
source: string;
|
||||
target: string;
|
||||
strength: number;
|
||||
}
|
||||
|
||||
const bridgeSet = new Set(bridgeNotes.map(b => b.noteId));
|
||||
|
||||
const nodes: D3Node[] = visibleNotes.map(n => {
|
||||
const cluster = clusters.find(c => c.id === n.clusterId);
|
||||
const isBridge = bridgeSet.has(n.id);
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
clusterId: n.clusterId!,
|
||||
color: cluster?.color || '#cbd5e1',
|
||||
isBridge,
|
||||
radius: isBridge ? 12 : 8
|
||||
};
|
||||
});
|
||||
|
||||
const links: D3Link[] = [];
|
||||
// Only connect strong links
|
||||
for (let i = 0; i < visibleNotes.length; i++) {
|
||||
for (let j = i + 1; j < visibleNotes.length; j++) {
|
||||
const ni = visibleNotes[i];
|
||||
const nj = visibleNotes[j];
|
||||
|
||||
if (ni.clusterId === nj.clusterId) {
|
||||
links.push({ source: ni.id, target: nj.id, strength: 0.5 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const simulation = d3.forceSimulation<D3Node>(nodes)
|
||||
.force("link", d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(100))
|
||||
.force("charge", d3.forceManyBody().strength(-200))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collision", d3.forceCollide<D3Node>().radius(d => d.radius + 10));
|
||||
|
||||
// Links
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("stroke", "#e2e8f0")
|
||||
.attr("stroke-opacity", 0.6)
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Nodes
|
||||
const node = g.append("g")
|
||||
.selectAll(".node")
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "node cursor-pointer")
|
||||
.on("click", (event, d) => onNoteSelect(d.id))
|
||||
.call(d3.drag<SVGGElement, D3Node>()
|
||||
.on("start", dragstarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragended) as any);
|
||||
|
||||
node.append("circle")
|
||||
.attr("r", d => d.radius)
|
||||
.attr("fill", d => d.color)
|
||||
.attr("stroke", d => d.isBridge ? "#D4AF37" : "#fff")
|
||||
.attr("stroke-width", d => d.isBridge ? 3 : 2)
|
||||
.style("filter", d => d.isBridge ? "drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))" : "none");
|
||||
|
||||
node.append("text")
|
||||
.attr("dy", d => d.radius + 14)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("class", "text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none")
|
||||
.text(d => d.title.length > 20 ? d.title.substring(0, 20) + "..." : d.title);
|
||||
|
||||
simulation.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => (d.source as any).x)
|
||||
.attr("y1", d => (d.source as any).y)
|
||||
.attr("x2", d => (d.target as any).x)
|
||||
.attr("y2", d => (d.target as any).y);
|
||||
|
||||
node
|
||||
.attr("transform", d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
function dragstarted(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event: any, d: D3Node) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
|
||||
return () => simulation.stop();
|
||||
}, [notes, clusters, bridgeNotes, onNoteSelect]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
|
||||
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-3 max-w-[300px]">
|
||||
{clusters.map(c => (
|
||||
<div key={c.id} className="flex items-center gap-1.5 px-2 py-1 bg-white/80 dark:bg-white/5 backdrop-blur-sm border border-border rounded-full shadow-sm">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-concrete whitespace-nowrap">{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
647
architectural-grid14/src/components/NotebooksView.tsx
Normal file
647
architectural-grid14/src/components/NotebooksView.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Pin,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
MoreVertical,
|
||||
Sparkles,
|
||||
Tag as TagIcon,
|
||||
X,
|
||||
BookOpen,
|
||||
Edit3,
|
||||
Eye,
|
||||
Trash2,
|
||||
Wind,
|
||||
FileText,
|
||||
Paperclip,
|
||||
Loader2,
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Note, Carnet, Tag, Attachment } from '../types';
|
||||
import { SlashMenu } from './SlashMenu';
|
||||
import { parseDocument } from '../services/geminiService';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface NotebooksViewProps {
|
||||
activeNoteId: string | null;
|
||||
activeCarnet: Carnet | undefined;
|
||||
filteredNotes: Note[];
|
||||
activeNote: Note | undefined;
|
||||
setActiveNoteId: (id: string | null) => void;
|
||||
togglePin: (id: string) => void;
|
||||
setShowNewNoteModal: (show: boolean) => void;
|
||||
isAISidebarOpen: boolean;
|
||||
setIsAISidebarOpen: (open: boolean) => void;
|
||||
selectedTagIds: string[];
|
||||
setSelectedTagIds: (ids: string[]) => void;
|
||||
allNotes: Note[];
|
||||
activeCarnetId: string;
|
||||
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
|
||||
onDeleteNote: (id: string) => void;
|
||||
onBrainstormNote: (note: Note) => void;
|
||||
onUpdateNote?: (note: Note) => void;
|
||||
}
|
||||
|
||||
export const NotebooksView: React.FC<NotebooksViewProps> = ({
|
||||
activeNoteId,
|
||||
activeCarnet,
|
||||
filteredNotes,
|
||||
activeNote,
|
||||
setActiveNoteId,
|
||||
togglePin,
|
||||
setShowNewNoteModal,
|
||||
isAISidebarOpen,
|
||||
setIsAISidebarOpen,
|
||||
selectedTagIds,
|
||||
setSelectedTagIds,
|
||||
allNotes,
|
||||
activeCarnetId,
|
||||
setShowNewCarnetModal,
|
||||
onDeleteNote,
|
||||
onBrainstormNote,
|
||||
onUpdateNote
|
||||
}) => {
|
||||
const [isTagsExpanded, setIsTagsExpanded] = React.useState(false);
|
||||
const [tagSearchQuery, setTagSearchQuery] = React.useState('');
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [slashMenu, setSlashMenu] = React.useState<{ isOpen: boolean; top: number; left: number } | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = React.useState<string | null>(null);
|
||||
const [activeDocQnA, setActiveDocQnA] = React.useState<Attachment | null>(null);
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !activeNote) return;
|
||||
|
||||
const newAttachment: Attachment = {
|
||||
id: uuidv4(),
|
||||
name: file.name,
|
||||
type: file.name.endsWith('.pdf') ? 'pdf' : (file.name.endsWith('.docx') ? 'docx' : 'other'),
|
||||
url: URL.createObjectURL(file), // Local preview url
|
||||
isProcessed: false
|
||||
};
|
||||
|
||||
const updatedNote = {
|
||||
...activeNote,
|
||||
attachments: [...(activeNote.attachments || []), newAttachment]
|
||||
};
|
||||
|
||||
onUpdateNote?.(updatedNote);
|
||||
|
||||
// Auto-analyze
|
||||
setIsAnalyzing(newAttachment.id);
|
||||
const content = await parseDocument(newAttachment.url, newAttachment.name);
|
||||
|
||||
const processedAttachment = { ...newAttachment, content, isProcessed: true };
|
||||
const finalNote = {
|
||||
...activeNote,
|
||||
attachments: [...(activeNote.attachments || []), processedAttachment]
|
||||
};
|
||||
onUpdateNote?.(finalNote);
|
||||
setIsAnalyzing(null);
|
||||
};
|
||||
|
||||
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === '/') {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
setSlashMenu({
|
||||
isOpen: true,
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const insertCommand = (type: string) => {
|
||||
console.log(`Command selected: ${type}`);
|
||||
setSlashMenu(null);
|
||||
};
|
||||
|
||||
const availableTags = React.useMemo(() => {
|
||||
const carnetNotes = allNotes.filter(n => n.carnetId === activeCarnetId);
|
||||
const tagsMap = new Map<string, Tag>();
|
||||
carnetNotes.forEach(note => {
|
||||
note.tags?.forEach(tag => {
|
||||
tagsMap.set(tag.id, tag);
|
||||
});
|
||||
});
|
||||
return Array.from(tagsMap.values()).sort((a, b) => {
|
||||
// AI tags first, then alphabetical
|
||||
if (a.type === 'ai' && b.type !== 'ai') return -1;
|
||||
if (a.type !== 'ai' && b.type === 'ai') return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}, [allNotes, activeCarnetId]);
|
||||
|
||||
const visibleTags = React.useMemo(() => {
|
||||
let filtered = availableTags;
|
||||
if (tagSearchQuery) {
|
||||
filtered = availableTags.filter(t =>
|
||||
t.label.toLowerCase().includes(tagSearchQuery.toLowerCase())
|
||||
);
|
||||
} else if (!isTagsExpanded) {
|
||||
filtered = availableTags.slice(0, 10);
|
||||
// Ensure selected tags are always visible even if not in the first 10
|
||||
selectedTagIds.forEach(id => {
|
||||
if (!filtered.find(t => t.id === id)) {
|
||||
const tag = availableTags.find(t => t.id === id);
|
||||
if (tag) filtered.push(tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, [availableTags, isTagsExpanded, tagSearchQuery, selectedTagIds]);
|
||||
|
||||
const toggleTag = (tagId: string) => {
|
||||
if (selectedTagIds.includes(tagId)) {
|
||||
setSelectedTagIds(selectedTagIds.filter(id => id !== tagId));
|
||||
} else {
|
||||
setSelectedTagIds([...selectedTagIds, tagId]);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeNoteId) {
|
||||
return (
|
||||
<div 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
|
||||
onClick={() => setShowNewCarnetModal(true, activeCarnetId)}
|
||||
className="flex items-center gap-2 text-[13px] text-concrete font-medium hover:text-ink transition-all"
|
||||
>
|
||||
<BookOpen size={16} />
|
||||
<span>New Sub-Carnet</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>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-[0.2em] text-concrete">
|
||||
<TagIcon size={12} />
|
||||
<span>Filter by Tags</span>
|
||||
{selectedTagIds.length > 0 && (
|
||||
<span className="bg-blueprint/10 text-blueprint px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
|
||||
{selectedTagIds.length} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{availableTags.length > 10 && (
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
className="bg-transparent border-b border-border/40 text-[10px] outline-none focus:border-blueprint/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-concrete/40"
|
||||
onChange={(e) => setTagSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center min-h-[32px]">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visibleTags.map(tag => {
|
||||
const isActive = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<motion.button
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all border flex items-center gap-2
|
||||
${isActive
|
||||
? 'bg-ink text-paper border-ink shadow-lg shadow-ink/10'
|
||||
: 'bg-white/40 border-border text-concrete hover:border-concrete/40 hover:bg-white/60'}`}
|
||||
>
|
||||
{tag.type === 'ai' && (
|
||||
<Sparkles
|
||||
size={10}
|
||||
className={isActive ? 'text-blueprint' : 'text-blueprint/60'}
|
||||
/>
|
||||
)}
|
||||
{tag.label}
|
||||
{isActive && <X size={10} />}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{availableTags.length > 10 && !tagSearchQuery && (
|
||||
<button
|
||||
onClick={() => setIsTagsExpanded(!isTagsExpanded)}
|
||||
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-concrete/60 hover:text-ink transition-colors border border-dashed border-border rounded-full"
|
||||
>
|
||||
{isTagsExpanded ? 'Show less' : `+ ${availableTags.length - 10} more`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedTagIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => setSelectedTagIds([])}
|
||||
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-rust hover:underline ml-auto"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</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 relative"
|
||||
onClick={() => setActiveNoteId(note.id)}
|
||||
>
|
||||
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
|
||||
<span className="flex items-center gap-3">
|
||||
{note.isPinned && <Pin size={18} className="text-amber-500 fill-amber-500" />}
|
||||
{note.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onBrainstormNote(note);
|
||||
}}
|
||||
className="p-2 rounded-full opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-ochre/10 text-ochre transition-all"
|
||||
title="Brainstorm this concept"
|
||||
>
|
||||
<Wind size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePin(note.id);
|
||||
}}
|
||||
className={`p-2 rounded-full transition-all ${note.isPinned ? 'text-amber-600 bg-amber-50' : 'opacity-0 group-hover:opacity-60 hover:bg-slate-100 text-ink'}`}
|
||||
>
|
||||
<Pin size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteNote(note.id);
|
||||
}}
|
||||
className="p-2 rounded-full opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-rose-50 text-rose-500 transition-all"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</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 dark:bg-white/5 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">
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{note.tags?.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border flex items-center gap-1.5
|
||||
${tag.type === 'ai'
|
||||
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
||||
: 'bg-concrete/5 border-border text-concrete'}`}
|
||||
>
|
||||
{tag.type === 'ai' && <Sparkles size={8} />}
|
||||
{tag.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex overflow-hidden transition-all duration-500">
|
||||
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-paper">
|
||||
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 dark:bg-paper/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={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border text-muted-ink hover:text-ink transition-all"
|
||||
title="Add attachment"
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
accept=".pdf,.docx,.txt"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onBrainstormNote(activeNote!)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-ochre/30 text-ochre hover:bg-ochre/5 transition-all"
|
||||
>
|
||||
<Wind size={16} />
|
||||
<span className="text-xs font-bold uppercase tracking-widest">Brainstorm</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg border transition-all duration-300
|
||||
${isEditing ? 'bg-blueprint text-white border-blueprint shadow-lg shadow-blueprint/20' : 'border-border text-ink hover:bg-slate-50'}`}
|
||||
>
|
||||
{isEditing ? <Eye size={16} /> : <Edit3 size={16} />}
|
||||
<span className="text-xs font-bold uppercase tracking-widest">{isEditing ? 'Visualiser' : 'Modifier'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => togglePin(activeNoteId!)}
|
||||
className={`p-2 rounded-full transition-all ${activeNote?.isPinned ? 'text-amber-600 bg-amber-50 dark:bg-ochre/10' : 'text-muted-ink hover:text-ink'}`}
|
||||
title={activeNote?.isPinned ? "Unpin note" : "Pin note"}
|
||||
>
|
||||
<Pin size={18} className={activeNote?.isPinned ? 'fill-amber-600' : ''} />
|
||||
</button>
|
||||
<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 dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
<span className="text-xs font-medium">AI Assistant</span>
|
||||
</button>
|
||||
<button className="p-2 text-muted-ink hover:text-red-500 transition-colors">
|
||||
<Trash2 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 relative">
|
||||
<AnimatePresence>
|
||||
{slashMenu?.isOpen && (
|
||||
<SlashMenu
|
||||
position={{ top: slashMenu.top, left: slashMenu.left }}
|
||||
onSelect={(type) => insertCommand(type)}
|
||||
onClose={() => setSlashMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
|
||||
<span className="text-blueprint">{activeCarnet?.name}</span>
|
||||
<ChevronRight size={10} className="text-concrete" />
|
||||
<span className="text-concrete">{activeNote?.date}</span>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={activeNote?.title}
|
||||
className="w-full text-5xl md:text-6xl font-serif font-bold text-ink leading-tight bg-transparent border-none outline-none focus:ring-0 placeholder:text-concrete/20"
|
||||
placeholder="Titre de la note..."
|
||||
/>
|
||||
) : (
|
||||
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
|
||||
{activeNote?.title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{activeNote?.tags?.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border flex items-center gap-2
|
||||
${tag.type === 'ai'
|
||||
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
||||
: 'bg-paper border-border text-concrete'}`}
|
||||
>
|
||||
{tag.type === 'ai' && <Sparkles size={12} />}
|
||||
{tag.label}
|
||||
{tag.type === 'ai' && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blueprint animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-white/5 rounded-xl overflow-hidden shadow-2xl relative group/img">
|
||||
<img
|
||||
src={activeNote?.imageUrl}
|
||||
alt={activeNote?.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover/img:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-ink/20 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl mx-auto w-full space-y-8 pb-40">
|
||||
{activeNote?.attachments && activeNote.attachments.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-[10px] uppercase font-bold tracking-widest text-concrete border-b border-border pb-2">Pièces jointes ({activeNote.attachments.length})</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{activeNote.attachments.map(att => (
|
||||
<div key={att.id} className="p-3 border border-border rounded-xl bg-white/40 flex items-center justify-between group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blueprint/10 text-blueprint rounded-lg">
|
||||
<FileText size={16} />
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-xs font-bold text-ink truncate w-32">{att.name}</p>
|
||||
<p className="text-[9px] uppercase font-medium text-concrete">{att.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isAnalyzing === att.id ? (
|
||||
<Loader2 size={14} className="animate-spin text-blueprint" />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setActiveDocQnA(att)}
|
||||
className="p-2 hover:bg-blueprint/10 text-blueprint rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Converser avec ce document"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
defaultValue={activeNote?.content}
|
||||
onKeyDown={handleEditorKeyDown}
|
||||
className="w-full min-h-[500px] text-lg leading-relaxed text-ink/90 font-serif bg-transparent border-none outline-none focus:ring-0 resize-none placeholder:text-concrete/20"
|
||||
placeholder="Commencez à écrire... Tapez '/' pour les commandes."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<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" />
|
||||
<div className="space-y-6">
|
||||
{activeNote?.content.split('\n').map((line, i) => (
|
||||
<p key={i} className="text-lg leading-relaxed text-ink/80 font-light text-justify selection:bg-blueprint/20">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
{activeNote?.id.startsWith('n-') && (
|
||||
<p className="text-lg leading-relaxed text-ink/80 font-light text-justify border-l-2 border-blueprint/20 pl-6 italic">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Q&A Overlay */}
|
||||
<AnimatePresence>
|
||||
{activeDocQnA && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-8 bg-ink/30 backdrop-blur-md">
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="w-full max-w-4xl h-full bg-paper dark:bg-dark-paper border border-border rounded-[32px] shadow-2xl flex overflow-hidden"
|
||||
>
|
||||
{/* Document Preview (Mock) */}
|
||||
<div className="flex-1 bg-slate-50 dark:bg-black/20 border-r border-border p-12 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="p-3 bg-blueprint text-white rounded-xl">
|
||||
<FileText size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-serif font-bold text-ink">{activeDocQnA.name}</h3>
|
||||
<p className="text-[10px] uppercase font-bold tracking-widest text-concrete">DOCUMENT SOURCE ANALYSÉ</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose prose-sm prose-slate dark:prose-invert max-w-none">
|
||||
<div className="whitespace-pre-wrap font-serif text-lg leading-relaxed text-ink/80 italic">
|
||||
{activeDocQnA.content || "Analyse du contenu en cours..."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Side */}
|
||||
<div className="w-[400px] flex flex-col bg-white dark:bg-paper">
|
||||
<div className="p-6 border-b border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={18} className="text-blueprint" />
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-ink">Expert Document</h4>
|
||||
</div>
|
||||
<button onClick={() => setActiveDocQnA(null)} className="p-2 hover:bg-slate-50 dark:hover:bg-white/5 rounded-full text-concrete">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 overflow-y-auto space-y-6">
|
||||
<div className="p-4 bg-blueprint/5 border border-blueprint/10 rounded-2xl">
|
||||
<p className="text-xs text-blueprint font-medium leading-relaxed">
|
||||
Bonjour ! J'ai analysé ce document. Posez-moi n'importe quelle question sur son contenu, les chiffres clés ou les concepts abordés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-border">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
placeholder="Poser une question au document..."
|
||||
className="w-full bg-slate-50 dark:bg-black/20 border border-border rounded-xl p-4 pr-12 text-xs outline-none focus:border-blueprint transition-all resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
<button className="absolute right-3 bottom-3 p-2 bg-blueprint text-white rounded-lg shadow-lg shadow-blueprint/10">
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
architectural-grid14/src/components/SettingsView.tsx
Normal file
66
architectural-grid14/src/components/SettingsView.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { SettingsTab } from '../types';
|
||||
import { SettingsHeader } from './settings/SettingsHeader';
|
||||
import { GeneralTab } from './settings/GeneralTab';
|
||||
import { AITab } from './settings/AITab';
|
||||
import { AppearanceTab } from './settings/AppearanceTab';
|
||||
|
||||
interface SettingsViewProps {
|
||||
activeSettingsTab: SettingsTab;
|
||||
setActiveSettingsTab: (tab: SettingsTab) => void;
|
||||
}
|
||||
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
activeSettingsTab,
|
||||
setActiveSettingsTab
|
||||
}) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-paper dark:bg-dark-paper overflow-y-auto custom-scrollbar relative font-sans">
|
||||
<div className="absolute inset-0 opacity-[0.04] pointer-events-none grainy-bg mix-blend-multiply dark:mix-blend-overlay" />
|
||||
|
||||
<div className="relative z-10 flex flex-col min-h-full">
|
||||
<SettingsHeader
|
||||
activeTab={activeSettingsTab}
|
||||
setActiveTab={setActiveSettingsTab}
|
||||
/>
|
||||
|
||||
<div className="flex-1 px-12 pb-24 h-full">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeSettingsTab === 'general' && (
|
||||
<GeneralTab key="general" />
|
||||
)}
|
||||
|
||||
{activeSettingsTab === 'ai' && (
|
||||
<AITab key="ai" />
|
||||
)}
|
||||
|
||||
{activeSettingsTab === 'appearance' && (
|
||||
<AppearanceTab key="appearance" />
|
||||
)}
|
||||
|
||||
{['profile', 'data', 'mcp', 'about'].includes(activeSettingsTab) && (
|
||||
<motion.div
|
||||
key="placeholder"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="h-[50vh] flex flex-col items-center justify-center border border-dashed border-border rounded-[32px] space-y-6 bg-white/20 dark:bg-white/5"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-3xl border border-dashed border-concrete/20 flex items-center justify-center text-concrete/40 bg-paper/50">
|
||||
<span className="text-2xl font-serif italic text-concrete">?</span>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-ink font-bold text-sm tracking-tight">Section en développement</p>
|
||||
<p className="text-concrete italic text-[11px] font-light">Le module {activeSettingsTab} sera disponible prochainement.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
569
architectural-grid14/src/components/Sidebar.tsx
Normal file
569
architectural-grid14/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Archive,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Microscope,
|
||||
Activity,
|
||||
Pin,
|
||||
Moon,
|
||||
Sun,
|
||||
Bell,
|
||||
Lock,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Users,
|
||||
Clock,
|
||||
GripVertical,
|
||||
Wind,
|
||||
Network
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { NavigationView, Carnet, Note } from '../types';
|
||||
|
||||
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 dark:bg-white/10 text-ink font-medium' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 truncate">
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${isActive ? 'bg-ink' : 'bg-transparent border border-muted-ink/30'}`} />
|
||||
<span className="truncate">{note.title}</span>
|
||||
</div>
|
||||
{note.isPinned && <Pin size={10} className="text-amber-500 shrink-0" />}
|
||||
</motion.button>
|
||||
);
|
||||
|
||||
interface SidebarItemProps {
|
||||
carnet: Carnet;
|
||||
isActive: boolean;
|
||||
notes: Note[];
|
||||
activeNoteId: string | null;
|
||||
onCarnetClick: () => void;
|
||||
onNoteClick: (noteId: string) => void;
|
||||
onAddSubCarnet: () => void;
|
||||
onRename: () => void;
|
||||
onDelete: () => void;
|
||||
children?: React.ReactNode;
|
||||
level: number;
|
||||
isExpanded: boolean;
|
||||
toggleExpand: () => void;
|
||||
onMove?: (draggedId: string, targetId?: string) => void;
|
||||
}
|
||||
|
||||
const SidebarItem: React.FC<SidebarItemProps> = ({
|
||||
carnet,
|
||||
isActive,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onCarnetClick,
|
||||
onNoteClick,
|
||||
onAddSubCarnet,
|
||||
onRename,
|
||||
onDelete,
|
||||
children,
|
||||
level,
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
onMove
|
||||
}) => {
|
||||
const hasChildren = React.Children.count(children) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div
|
||||
className="flex items-center group relative h-10"
|
||||
style={{ paddingLeft: `${level * 12}px` }}
|
||||
>
|
||||
{/* Subtle Drag Handle */}
|
||||
<div className="absolute left-[-2px] opacity-0 group-hover:opacity-40 cursor-grab active:cursor-grabbing text-concrete transition-opacity z-10">
|
||||
<GripVertical size={10} />
|
||||
</div>
|
||||
|
||||
{/* Hierarchy Guide Line */}
|
||||
{level > 0 && (
|
||||
<div className="absolute left-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
|
||||
)}
|
||||
{level > 0 && (
|
||||
<div className="absolute left-[8px] top-1/2 w-[8px] h-px bg-border/40" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand();
|
||||
}}
|
||||
className="p-1 hover:bg-ink/5 dark:hover:bg-white/5 rounded-md transition-colors text-muted-ink"
|
||||
>
|
||||
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
||||
<ChevronRight size={14} />
|
||||
</motion.div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6" /> // Spacer for alignment
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
whileHover={{ x: 2 }}
|
||||
className={`flex-1 flex items-center gap-2 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative
|
||||
${isActive ? 'bg-white shadow-sm border border-border/40 dark:bg-white/10' : 'hover:bg-white/40 dark:hover:bg-white/5'}`}
|
||||
onClick={onCarnetClick}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.currentTarget.classList.add('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
const draggedId = e.dataTransfer.getData('carnetId');
|
||||
console.log('Dropped carnet:', draggedId, 'on target:', carnet.id);
|
||||
if (draggedId && draggedId !== carnet.id) {
|
||||
onMove?.(draggedId, carnet.id);
|
||||
}
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
console.log('Starting drag for carnet:', carnet.id);
|
||||
e.dataTransfer.setData('carnetId', carnet.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
const ghost = e.currentTarget.cloneNode(true) as HTMLElement;
|
||||
ghost.style.position = 'absolute';
|
||||
ghost.style.top = '-1000px';
|
||||
ghost.style.opacity = '0.5';
|
||||
document.body.appendChild(ghost);
|
||||
e.dataTransfer.setDragImage(ghost, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(ghost), 0);
|
||||
}}
|
||||
>
|
||||
{/* active indicator dot */}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-indicator"
|
||||
className="absolute -left-1 w-1 h-4 bg-blueprint rounded-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border transition-all
|
||||
${isActive ? 'bg-blueprint text-white border-blueprint' : 'bg-paper dark:bg-white/10 text-concrete border-border dark:border-white/10'}`}>
|
||||
{carnet.initial}
|
||||
</div>
|
||||
<div className="flex-1 text-left flex items-center gap-2 min-w-0">
|
||||
<span className={`text-[12px] font-medium transition-colors truncate ${isActive ? 'text-ink' : 'text-muted-ink group-hover:text-ink'}`}>
|
||||
{carnet.name}
|
||||
</span>
|
||||
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddSubCarnet();
|
||||
}}
|
||||
className="p-1 hover:bg-ink/10 dark:hover:bg-white/10 rounded-md transition-all text-concrete hover:text-ink"
|
||||
title="Add sub-carnet"
|
||||
>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRename();
|
||||
}}
|
||||
className="p-1 hover:bg-ink/10 dark:hover:bg-white/10 rounded-md transition-all text-concrete hover:text-ink"
|
||||
title="Rename"
|
||||
>
|
||||
<Edit3 size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-1 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-all text-concrete hover:text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
|
||||
{notes.length > 0 && (
|
||||
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover:text-concrete transition-colors">
|
||||
{notes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{(isExpanded || (isActive && !hasChildren)) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative" style={{ marginLeft: `${(level + 1) * 12 + 10}px` }}>
|
||||
{/* Vertical line for nested content */}
|
||||
<div className="absolute left-[-6px] top-0 bottom-4 w-px bg-border/30" />
|
||||
|
||||
<div className="space-y-1 py-1">
|
||||
{children}
|
||||
{isActive && !hasChildren && notes.map(note => (
|
||||
<NoteLink
|
||||
key={note.id}
|
||||
note={note}
|
||||
isActive={activeNoteId === note.id}
|
||||
onClick={() => onNoteClick(note.id)}
|
||||
/>
|
||||
))}
|
||||
{isActive && !hasChildren && notes.length === 0 && (
|
||||
<p className="pl-8 py-2 text-[10px] italic text-concrete/40 font-light">
|
||||
No notes found
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
activeView: NavigationView;
|
||||
isDarkMode: boolean;
|
||||
setIsDarkMode: (val: boolean) => void;
|
||||
setActiveView: (view: NavigationView) => void;
|
||||
carnets: Carnet[];
|
||||
notes: Note[];
|
||||
activeCarnetId: string;
|
||||
activeNoteId: string | null;
|
||||
setActiveCarnetId: (id: string) => void;
|
||||
setActiveNoteId: (id: string | null) => void;
|
||||
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
|
||||
onDeleteCarnet: (id: string) => void;
|
||||
onMoveCarnet: (draggedId: string, targetId?: string) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
activeView,
|
||||
isDarkMode,
|
||||
setIsDarkMode,
|
||||
setActiveView,
|
||||
carnets,
|
||||
notes,
|
||||
activeCarnetId,
|
||||
activeNoteId,
|
||||
setActiveCarnetId,
|
||||
setActiveNoteId,
|
||||
setShowNewCarnetModal,
|
||||
onDeleteCarnet,
|
||||
onMoveCarnet
|
||||
}) => {
|
||||
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set(['4'])); // Default expand Research
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
const newSet = new Set(expandedIds);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
setExpandedIds(newSet);
|
||||
};
|
||||
|
||||
const renderCarnetTree = (parentId: string | undefined = undefined, level: number = 0) => {
|
||||
return carnets
|
||||
.filter(c => c.parentId === parentId && !c.isDeleted)
|
||||
.map(carnet => (
|
||||
<SidebarItem
|
||||
key={carnet.id}
|
||||
carnet={carnet}
|
||||
isActive={activeCarnetId === carnet.id}
|
||||
notes={notes.filter(n => n.carnetId === carnet.id && !n.isDeleted)}
|
||||
activeNoteId={activeNoteId}
|
||||
level={level}
|
||||
isExpanded={expandedIds.has(carnet.id)}
|
||||
toggleExpand={() => toggleExpand(carnet.id)}
|
||||
onAddSubCarnet={() => {
|
||||
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
|
||||
setShowNewCarnetModal(true, carnet.id);
|
||||
}}
|
||||
onRename={() => {
|
||||
setShowNewCarnetModal(true, undefined, true, carnet.id);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDeleteCarnet(carnet.id);
|
||||
}}
|
||||
onCarnetClick={() => {
|
||||
setActiveCarnetId(carnet.id);
|
||||
setActiveNoteId(null);
|
||||
// Auto expand when clicking
|
||||
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
|
||||
}}
|
||||
onNoteClick={(id) => {
|
||||
setActiveCarnetId(carnet.id);
|
||||
setActiveNoteId(id);
|
||||
}}
|
||||
onMove={onMoveCarnet}
|
||||
>
|
||||
{renderCarnetTree(carnet.id, level + 1)}
|
||||
</SidebarItem>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-80 bg-white/30 dark:bg-[#151515] backdrop-blur-md border-r border-border p-6 flex flex-col z-20 shrink-0 transition-colors duration-500">
|
||||
<div className="mb-10 flex items-center justify-between">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-200 dark:bg-white/10 border border-border flex items-center justify-center text-ink font-serif text-lg shadow-sm">
|
||||
A
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
className="p-2 text-muted-ink hover:text-ink transition-all bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10"
|
||||
>
|
||||
{isDarkMode ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
|
||||
<button className="p-2 text-muted-ink hover:text-ink transition-all relative group bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10">
|
||||
<Bell size={14} />
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-rose-500 text-white text-[9px] font-bold flex items-center justify-center rounded-full border border-white shadow-sm">
|
||||
3
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex bg-white/50 dark:bg-white/10 p-1 rounded-full border border-border dark:border-white/10 transition-all">
|
||||
<button
|
||||
onClick={() => setActiveView('notebooks')}
|
||||
className={`p-1.5 rounded-full transition-all ${activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
|
||||
title="Carnets"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('reminders')}
|
||||
className={`p-1.5 rounded-full transition-all ${activeView === 'reminders' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
|
||||
title="Rappels"
|
||||
>
|
||||
<Clock size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('agents')}
|
||||
className={`p-1.5 rounded-full transition-all ${activeView === 'agents' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
|
||||
title="Agents"
|
||||
>
|
||||
<Bot size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-8 -mx-2 px-2 py-4 custom-scrollbar">
|
||||
{activeView === 'notebooks' ? (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-1 rounded-lg transition-colors group/header"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
e.currentTarget.classList.add('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
|
||||
const draggedId = e.dataTransfer.getData('carnetId');
|
||||
console.log('Dropped carnet on root:', draggedId);
|
||||
if (draggedId) {
|
||||
onMoveCarnet(draggedId, undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">
|
||||
Architecture Grid
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewCarnetModal(true)}
|
||||
className="p-1 hover:bg-paper dark:hover:bg-white/5 rounded-md text-concrete hover:text-ink transition-colors"
|
||||
title="New Carnet"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-0.5">
|
||||
{renderCarnetTree()}
|
||||
</nav>
|
||||
</div>
|
||||
) : activeView === 'shared' ? (
|
||||
<div className="space-y-6">
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4">
|
||||
Partagé avec moi
|
||||
</p>
|
||||
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
|
||||
<Users size={24} className="mx-auto text-concrete/40 mb-3" />
|
||||
<p className="text-[11px] text-concrete italic">Aucun document partagé pour le moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : activeView === 'reminders' ? (
|
||||
<div className="space-y-6">
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4">
|
||||
Rappels programmés
|
||||
</p>
|
||||
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
|
||||
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
|
||||
<p className="text-[11px] text-concrete italic">Aucun rappel actif.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : activeView === 'agents' ? (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-muted-ink tracking-widest uppercase mb-4 px-4">
|
||||
Intelligence OS
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ id: 'a1', name: 'Mes Agents', icon: <Bot size={16} /> },
|
||||
{ id: 'a2', name: 'Le Lab AI', icon: <Microscope size={16} /> },
|
||||
{ id: 'a3', name: 'Activités', icon: <Activity size={16} /> },
|
||||
].map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
|
||||
${item.id === 'a1' ? 'active-nav-item' : 'text-muted-ink hover:bg-white/40 dark:hover:bg-white/5 hover:text-ink'}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center border transition-colors
|
||||
${item.id === 'a1' ? 'bg-ink text-paper border-ink' : 'bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-ink/20'}`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="text-[13px] font-medium">{item.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-1">
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4 mb-2">
|
||||
Capabilities
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveView('brainstorm')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-ochre hover:bg-ochre/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-ochre/20">
|
||||
<Wind size={16} />
|
||||
</div>
|
||||
<span className="flex-1 text-left">Brainstorm Wave</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('insights')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-indigo-500 hover:bg-indigo-500/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-indigo-500/20">
|
||||
<Network size={16} />
|
||||
</div>
|
||||
<span className="flex-1 text-left">Semantic Network</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('temporal')}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-rose-500 hover:bg-rose-500/5"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-rose-500/20">
|
||||
<Clock size={16} />
|
||||
</div>
|
||||
<span className="flex-1 text-left">Temporal Forecast</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border/40 mt-auto pb-4">
|
||||
<div className="px-2 space-y-0.5">
|
||||
<button
|
||||
onClick={() => setActiveView('shared')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'shared' ? 'bg-blueprint/5 text-blueprint' : 'text-muted-ink hover:text-ink hover:bg-black/5'}`}
|
||||
>
|
||||
<Users size={14} className={activeView === 'shared' ? 'text-blueprint' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<span className="flex-1 text-left">Partagé</span>
|
||||
</button>
|
||||
|
||||
<button className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-black/5 transition-all font-medium group rounded-xl">
|
||||
<Archive size={14} className="text-muted-ink group-hover:text-ink" />
|
||||
<span className="flex-1 text-left">Archives</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('trash')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'trash' ? 'bg-rose-50 text-rose-500' : 'text-muted-ink hover:text-rose-500 hover:bg-rose-50/50'}`}
|
||||
>
|
||||
<Trash2 size={14} className={activeView === 'trash' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
|
||||
<span className="flex-1 text-left">Corbeille</span>
|
||||
{notes.some(n => n.isDeleted) && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-rose-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="my-4 pt-4 border-t border-border/20">
|
||||
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase px-3 mb-2 opacity-60">Intelligence</p>
|
||||
<button
|
||||
onClick={() => setActiveView('brainstorm')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'brainstorm' ? 'bg-ochre/10 text-ochre' : 'text-muted-ink hover:text-ochre hover:bg-ochre/5'}`}
|
||||
>
|
||||
<Wind size={14} className={activeView === 'brainstorm' ? 'text-ochre' : 'text-muted-ink group-hover:text-ochre'} />
|
||||
<span className="flex-1 text-left">Brainstorm Wave</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('insights')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'insights' ? 'bg-indigo-500/10 text-indigo-500' : 'text-muted-ink hover:text-indigo-500 hover:bg-indigo-500/5'}`}
|
||||
>
|
||||
<Network size={14} className={activeView === 'insights' ? 'text-indigo-500' : 'text-muted-ink group-hover:text-indigo-500'} />
|
||||
<span className="flex-1 text-left">Semantic Network</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('temporal')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'temporal' ? 'bg-rose-500/10 text-rose-500' : 'text-muted-ink hover:text-rose-500 hover:bg-rose-500/5'}`}
|
||||
>
|
||||
<Clock size={14} className={activeView === 'temporal' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
|
||||
<span className="flex-1 text-left">Temporal Forecast</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="my-2 h-px bg-border/20 mx-2" />
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('settings')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'settings' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-black/5'}`}
|
||||
>
|
||||
<Settings size={14} className={activeView === 'settings' ? 'text-paper' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<span className="flex-1 text-left">Paramètres</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
65
architectural-grid14/src/components/SlashMenu.tsx
Normal file
65
architectural-grid14/src/components/SlashMenu.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
List,
|
||||
Quote,
|
||||
Code,
|
||||
Image as ImageIcon,
|
||||
Type,
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
interface SlashMenuProps {
|
||||
position: { top: number; left: number };
|
||||
onSelect: (type: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SlashMenu: React.FC<SlashMenuProps> = ({ position, onSelect, onClose }) => {
|
||||
const commands = [
|
||||
{ id: 'h1', label: 'Titre Principal', icon: <Heading1 size={14} />, desc: 'Grand titre de section' },
|
||||
{ id: 'h2', label: 'Sous-titre', icon: <Heading2 size={14} />, desc: 'Titre de niveau 2' },
|
||||
{ id: 'bullet', label: 'Liste à puces', icon: <List size={14} />, desc: 'Liste simple' },
|
||||
{ id: 'quote', label: 'Citation', icon: <Quote size={14} />, desc: 'Bloc de texte mis en avant' },
|
||||
{ id: 'code', label: 'Bloc de Code', icon: <Code size={14} />, desc: 'Code ou texte technique' },
|
||||
{ id: 'image', label: 'Image', icon: <ImageIcon size={14} />, desc: 'Insérer un visuel' },
|
||||
{ id: 'ai-summary', label: 'Résumé IA', icon: <Sparkles size={14} />, desc: 'Générer un résumé court', special: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[60]" onClick={onClose} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
className="fixed z-[70] w-64 bg-white dark:bg-[#1A1A1A] border border-border shadow-2xl rounded-xl overflow-hidden py-2"
|
||||
style={{ top: position.top, left: position.left }}
|
||||
>
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-concrete uppercase tracking-widest border-b border-border/40 mb-1">
|
||||
Commandes rapides
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto custom-scrollbar">
|
||||
{commands.map((cmd) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
onClick={() => onSelect(cmd.id)}
|
||||
className="w-full flex items-start gap-3 px-3 py-2 hover:bg-paper dark:hover:bg-white/5 transition-colors group text-left"
|
||||
>
|
||||
<div className={`p-2 rounded-lg border border-border transition-colors group-hover:border-ink/20
|
||||
${cmd.special ? 'bg-blueprint/10 text-blueprint border-blueprint/20' : 'bg-white/50 dark:bg-white/5 text-ink'}`}>
|
||||
{cmd.icon}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-bold text-ink">{cmd.label}</p>
|
||||
<p className="text-[10px] text-muted-ink leading-tight">{cmd.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
169
architectural-grid14/src/components/TemporalView.tsx
Normal file
169
architectural-grid14/src/components/TemporalView.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
Clock,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
Zap,
|
||||
History,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
PieChart
|
||||
} from 'lucide-react';
|
||||
import { Note, NoteAccessLog, NotePrediction } from '../types';
|
||||
import { predictNextAccess, detectAccessCycle } from '../services/temporalService';
|
||||
|
||||
interface TemporalViewProps {
|
||||
notes: Note[];
|
||||
accessLogs: NoteAccessLog[];
|
||||
onNoteSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export const TemporalView: React.FC<TemporalViewProps> = ({ notes, accessLogs, onNoteSelect }) => {
|
||||
const predictions = useMemo(() => {
|
||||
return notes
|
||||
.map(note => {
|
||||
const noteLogs = accessLogs.filter(l => l.noteId === note.id);
|
||||
const prediction = predictNextAccess(note, noteLogs);
|
||||
return { note, prediction };
|
||||
})
|
||||
.filter(p => p.prediction !== null) as { note: Note; prediction: NotePrediction }[];
|
||||
}, [notes, accessLogs]);
|
||||
|
||||
const cyclicalNotes = useMemo(() => {
|
||||
return notes
|
||||
.map(note => {
|
||||
const noteLogs = accessLogs.filter(l => l.noteId === note.id);
|
||||
const cycle = detectAccessCycle(noteLogs);
|
||||
return { note, cycle };
|
||||
})
|
||||
.filter(p => p.cycle !== null) as { note: Note; cycle: number }[];
|
||||
}, [notes, accessLogs]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-paper dark:bg-[#0A0A0A] overflow-hidden">
|
||||
<div className="p-8 border-b border-border/40 backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-rose-500/10 flex items-center justify-center text-rose-500">
|
||||
<Clock size={18} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Temporal Forecast</h1>
|
||||
</div>
|
||||
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Predicting the recurrence of insight</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
|
||||
<div className="max-w-5xl mx-auto space-y-12">
|
||||
|
||||
{/* Daily Briefing / Predictions */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Sparkles size={16} className="text-rose-400" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Intelligence Briefing</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{predictions.map(({ note, prediction }) => (
|
||||
<motion.div
|
||||
key={note.id}
|
||||
whileHover={{ y: -4 }}
|
||||
onClick={() => onNoteSelect(note.id)}
|
||||
className="p-6 rounded-2xl bg-white dark:bg-white/5 border border-border/60 hover:border-rose-400/40 transition-all cursor-pointer shadow-sm relative overflow-hidden group"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-3">
|
||||
<TrendingUp size={14} className="text-rose-400 opacity-40 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="px-2 py-1 bg-rose-500/10 text-rose-500 text-[10px] font-bold rounded-full uppercase tracking-widest">
|
||||
Coming Up
|
||||
</span>
|
||||
<span className="text-[10px] text-concrete font-medium">
|
||||
{new Date(prediction.predictedRelevanceDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="text-lg font-serif font-medium text-ink dark:text-dark-ink mb-2 group-hover:text-rose-500 transition-colors uppercase tracking-tight">{note.title}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-6 italic">
|
||||
"{prediction.reason}"
|
||||
</p>
|
||||
|
||||
<div className="pt-4 border-t border-border/40 flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-concrete uppercase tracking-widest">Confidence {Math.round(prediction.confidence * 100)}%</span>
|
||||
<ArrowRight size={14} className="text-concrete group-hover:text-ink" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{predictions.length === 0 && (
|
||||
<div className="col-span-full p-12 text-center bg-paper dark:bg-white/5 border-2 border-dashed border-border rounded-3xl">
|
||||
<Calendar size={40} className="mx-auto text-concrete/40 mb-4" />
|
||||
<h5 className="text-ink dark:text-dark-ink font-medium mb-1">No upcoming predictions</h5>
|
||||
<p className="text-xs text-concrete">The system needs more usage data to find cyclical patterns in your research.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cyclical Patterns */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<History size={16} className="text-indigo-400" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Detected Cycles</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{cyclicalNotes.map(({ note, cycle }) => (
|
||||
<div key={note.id} className="flex items-center gap-4 p-4 rounded-xl bg-slate-50 dark:bg-white/5 border border-border/40">
|
||||
<div className="w-12 h-12 rounded-xl bg-white dark:bg-black/20 border border-border flex items-center justify-center flex-col shadow-sm">
|
||||
<span className="text-xs font-bold text-ink dark:text-dark-ink">{Math.round(cycle)}</span>
|
||||
<span className="text-[8px] font-bold text-concrete uppercase">days</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="text-sm font-medium text-ink dark:text-dark-ink">{note.title}</h5>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-concrete">Recurring theme in your architectural process</span>
|
||||
<div className="h-1 w-24 bg-border/40 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-rose-400" style={{ width: '65%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => onNoteSelect(note.id)} className="p-2 hover:bg-black/5 rounded-full">
|
||||
<ArrowRight size={16} className="text-concrete" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Productivity Stats */}
|
||||
<section className="bg-ink dark:bg-white/5 rounded-3xl p-8 text-paper relative overflow-hidden">
|
||||
<div className="relative z-10 grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h6 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-60 mb-2">Memory Strength</h6>
|
||||
<div className="text-4xl font-serif">84%</div>
|
||||
<p className="text-[10px] opacity-60 mt-2">Active connections in your semantic network</p>
|
||||
</div>
|
||||
<div>
|
||||
<h6 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-60 mb-2">Peak Cycle</h6>
|
||||
<div className="text-4xl font-serif">28 Days</div>
|
||||
<p className="text-[10px] opacity-60 mt-2">The rhythm of your creative output</p>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-2 h-2 rounded-full bg-rose-400"></div>
|
||||
<span className="text-xs font-medium">4 Notes resurfacing this week</span>
|
||||
</div>
|
||||
<button className="w-full py-2 bg-paper text-ink rounded-xl text-[10px] font-bold uppercase tracking-widest hover:scale-105 transition-transform">
|
||||
Build Morning Briefing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-rose-500/10 blur-[80px] rounded-full -mr-20 -mt-20"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
218
architectural-grid14/src/components/TrashView.tsx
Normal file
218
architectural-grid14/src/components/TrashView.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
X,
|
||||
FileText,
|
||||
Folder,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Note, Carnet } from '../types';
|
||||
|
||||
interface TrashViewProps {
|
||||
deletedNotes: Note[];
|
||||
deletedCarnets: Carnet[];
|
||||
onRestoreNote: (id: string) => void;
|
||||
onRestoreCarnet: (id: string) => void;
|
||||
onPermanentDeleteNote: (id: string) => void;
|
||||
onPermanentDeleteCarnet: (id: string) => void;
|
||||
onEmptyTrash: () => void;
|
||||
}
|
||||
|
||||
export const TrashView: React.FC<TrashViewProps> = ({
|
||||
deletedNotes,
|
||||
deletedCarnets,
|
||||
onRestoreNote,
|
||||
onRestoreCarnet,
|
||||
onPermanentDeleteNote,
|
||||
onPermanentDeleteCarnet,
|
||||
onEmptyTrash
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [filterType, setFilterType] = React.useState<'all' | 'notes' | 'carnets'>('all');
|
||||
|
||||
const getDaysRemaining = (dateString?: string) => {
|
||||
if (!dateString) return 30;
|
||||
const deletedDate = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = now.getTime() - deletedDate.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
return Math.max(0, 30 - diffDays);
|
||||
};
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
const items = [
|
||||
...deletedNotes.map(n => ({ ...n, itemType: 'note' as const })),
|
||||
...deletedCarnets.map(c => ({ ...c, itemType: 'carnet' as const }))
|
||||
];
|
||||
|
||||
return items
|
||||
.filter(item => {
|
||||
const matchesSearch = ('title' in item ? item.title : item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesType = filterType === 'all' || (filterType === 'notes' && item.itemType === 'note') || (filterType === 'carnets' && item.itemType === 'carnet');
|
||||
return matchesSearch && matchesType;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = a.deletedAt ? new Date(a.deletedAt).getTime() : 0;
|
||||
const dateB = b.deletedAt ? new Date(b.deletedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
}, [deletedNotes, deletedCarnets, searchQuery, filterType]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-dark-paper">
|
||||
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-[#F9F8F6]/80 backdrop-blur-md z-30 border-b border-border/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-4xl font-serif font-medium text-ink flex items-center gap-4">
|
||||
Corbeille <Trash2 size={28} className="text-rose-400 opacity-40" />
|
||||
</h1>
|
||||
<p className="text-[10px] text-concrete font-bold uppercase tracking-[0.3em] opacity-60">
|
||||
Auto-suppression après 30 jours
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{filteredItems.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Vider la corbeille ? Cette action est irréversible.')) {
|
||||
onEmptyTrash();
|
||||
}
|
||||
}}
|
||||
className="px-6 py-3 bg-paper border border-border text-rose-500 rounded-2xl text-[10px] font-bold uppercase tracking-widest hover:bg-rose-50 hover:border-rose-100 transition-all shadow-sm"
|
||||
>
|
||||
Vider tout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="group relative flex-1 max-w-xl">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-concrete group-focus-within:text-ink transition-colors" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-white dark:bg-white/5 border border-border/40 rounded-2xl pl-12 pr-6 py-4 text-sm outline-none focus:ring-4 ring-ink/5 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-white dark:bg-white/5 p-1 rounded-2xl border border-border shadow-sm">
|
||||
{(['all', 'notes', 'carnets'] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setFilterType(type)}
|
||||
className={`px-6 py-3 rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all
|
||||
${filterType === type ? 'bg-ink text-paper shadow-lg' : 'text-concrete hover:text-ink'}`}
|
||||
>
|
||||
{type === 'all' ? 'Tous' : type === 'notes' ? 'Notes' : 'Carnets'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-12 py-12 overflow-y-auto custom-scrollbar">
|
||||
{filteredItems.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredItems.map((item) => {
|
||||
const daysLeft = getDaysRemaining(item.deletedAt);
|
||||
return (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-white dark:bg-white/5 border border-border/60 rounded-[32px] p-8 group hover:shadow-2xl hover:border-blueprint/20 transition-all relative overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Countdown Progress Bar */}
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-slate-100 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(daysLeft / 30) * 100}%` }}
|
||||
className={`h-full ${daysLeft < 5 ? 'bg-rose-500' : 'bg-blueprint'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-3 rounded-2xl ${item.itemType === 'note' ? 'bg-blueprint/10 text-blueprint' : 'bg-concrete/10 text-concrete'}`}>
|
||||
{item.itemType === 'note' ? <FileText size={20} /> : <Folder size={20} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => item.itemType === 'note' ? onRestoreNote(item.id) : onRestoreCarnet(item.id)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-50 text-emerald-600 rounded-xl text-[10px] font-bold uppercase tracking-widest hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
<RotateCcw size={12} /> Restaurer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => item.itemType === 'note' ? onPermanentDeleteNote(item.id) : onPermanentDeleteCarnet(item.id)}
|
||||
className="p-2 hover:bg-rose-50 text-rose-500 rounded-xl transition-colors"
|
||||
title="Supprimer définitivement"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-8 flex-1">
|
||||
<h3 className="text-base font-serif font-medium text-ink leading-tight">
|
||||
{'title' in item ? item.title : item.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded border ${daysLeft < 5 ? 'border-rose-200 text-rose-500 bg-rose-50' : 'border-blueprint/20 text-blueprint bg-blueprint/5'}`}>
|
||||
{daysLeft} JOURS RESTANTS
|
||||
</div>
|
||||
<span className="text-[10px] text-concrete font-medium uppercase tracking-tight flex items-center gap-1">
|
||||
<Clock size={10} /> {('deletedAt' in item && item.deletedAt) ? new Date(item.deletedAt).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.itemType === 'note' && 'content' in item ? (
|
||||
<div className="text-[12px] text-concrete line-clamp-3 leading-relaxed opacity-60 font-light border-t border-border/40 pt-4">
|
||||
{item.content.replace(/[#*`]/g, '')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-border/40 pt-4">
|
||||
<div className="text-[9px] font-bold text-concrete/40 uppercase tracking-widest">
|
||||
Contenu du dossier préservé
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center space-y-6 opacity-40">
|
||||
<div className="p-8 rounded-full bg-slate-100 border-2 border-dashed border-border flex items-center justify-center">
|
||||
<Trash2 size={64} className="text-concrete" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-serif text-ink italic">Corbeille vide</h2>
|
||||
<p className="text-sm text-concrete max-w-xs">
|
||||
Les éléments que vous supprimez apparaîtront ici. Ils seront conservés pendant 30 jours avant suppression définitive.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="px-12 py-6 bg-white/50 border-t border-border flex items-center gap-4">
|
||||
<AlertCircle size={14} className="text-concrete" />
|
||||
<p className="text-[10px] text-concrete font-medium uppercase tracking-widest">
|
||||
Conseil : La restauration d'un carnet restaurera également toutes les notes à l'intérieur.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
152
architectural-grid14/src/components/settings/AITab.tsx
Normal file
152
architectural-grid14/src/components/settings/AITab.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { Sparkles, Edit3, MessageCircle, Languages, Tag, History, FlaskConical } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const AISettingCard = ({ icon, title, description, defaultChecked = false }: any) => (
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-6 flex items-center justify-between group hover:shadow-xl hover:shadow-blueprint/5 transition-all duration-300">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-blueprint/10 rounded-2xl text-blueprint group-hover:bg-blueprint group-hover:text-white group-hover:scale-110 transition-all duration-300 border border-blueprint/20">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-ink">{title}</h4>
|
||||
<p className="text-[10px] text-muted-ink leading-relaxed pr-4 line-clamp-2">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked={defaultChecked} />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-blueprint"></div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AITab: React.FC = () => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-16 pb-20"
|
||||
>
|
||||
<div className="space-y-10">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-muted-ink opacity-60">Configurez vos fonctionnalités IA et préférences</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-sm font-bold text-ink border-b border-border/40 pb-4">Fonctionnalités IA</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<AISettingCard
|
||||
icon={<Edit3 size={18} />}
|
||||
title="Suggestions de titre"
|
||||
description="Suggérer des titres pour les notes sans titre après 50+ mots"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<Sparkles size={18} />}
|
||||
title="IA Note"
|
||||
description="Active le bouton de chat IA et les outils d'amélioration du texte"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<MessageCircle size={18} />}
|
||||
title="💡 J'ai remarqué quelque chose..."
|
||||
description="Aperçu quotidien de vos notes"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<Languages size={18} />}
|
||||
title="Détection de langue"
|
||||
description="Détecte automatiquement la langue de vos notes"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<Tag size={18} />}
|
||||
title="Suggestion des labels"
|
||||
description="Suggère et applique des étiquettes automatiquement à vos notes"
|
||||
defaultChecked
|
||||
/>
|
||||
<AISettingCard
|
||||
icon={<History size={18} />}
|
||||
title="Historique des notes"
|
||||
description="Active les snapshots de versions et la restauration depuis History"
|
||||
defaultChecked
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6">
|
||||
{/* Fréquence */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
|
||||
<div className="space-y-1.5 text-left text-blueprint">
|
||||
<h4 className="text-sm font-bold">Fréquence</h4>
|
||||
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">Fréquence d'analyse des connexions</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="freq" className="sr-only peer" defaultChecked />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">Quotidienne</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="freq" className="sr-only peer" />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">Hebdomadaire</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode d'historique */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
|
||||
<div className="space-y-1.5 text-left text-blueprint">
|
||||
<h4 className="text-sm font-bold">Mode d'historique</h4>
|
||||
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">Gestion des snapshots</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="hist" className="sr-only peer" defaultChecked />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-bold text-ink">Manuel (bouton commit)</p>
|
||||
<p className="text-[10px] text-muted-ink">Créer des snapshots manuellement</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input type="radio" name="hist" className="sr-only peer" />
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-bold text-ink">Automatique (intelligent)</p>
|
||||
<p className="text-[10px] text-muted-ink">Snapshots automatiques avec détection</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Démo */}
|
||||
<div className="bg-ochre/5 dark:bg-ochre/10 border border-ochre/20 rounded-2xl p-8 flex items-center justify-between group transition-all duration-300 hover:bg-ochre/10">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="p-3 bg-paper dark:bg-ochre/20 rounded-2xl text-ochre border border-ochre/30">
|
||||
<FlaskConical size={20} />
|
||||
</div>
|
||||
<div className="space-y-1.5 text-left">
|
||||
<h4 className="text-sm font-bold text-ink flex items-center gap-3">
|
||||
🧪 Mode Démo
|
||||
</h4>
|
||||
<p className="text-[11px] text-muted-ink leading-relaxed font-medium">Accélère Memory Echo pour les tests. Les connexions apparaissent instantanément.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
|
||||
<input type="checkbox" className="sr-only peer" />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ochre"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
const AppearanceSelect = ({ icon, title, description, options, defaultValue }: any) => (
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-slate/5">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border group-hover:scale-110 transition-transform duration-300">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-0.5 text-left">
|
||||
<h4 className="text-base font-bold text-ink">{title}</h4>
|
||||
<p className="text-[11px] text-concrete leading-tight">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative group/select">
|
||||
<select
|
||||
defaultValue={defaultValue}
|
||||
className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-4 text-sm outline-none focus:ring-1 ring-slate/20 appearance-none cursor-pointer text-ink font-bold transition-all hover:bg-white dark:hover:bg-black/60"
|
||||
>
|
||||
{options.map((opt: string) => (
|
||||
<option key={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-concrete group-hover/select:text-slate transition-colors">
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AppearanceTab: React.FC = () => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-16 pb-20"
|
||||
>
|
||||
<div className="space-y-10">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">Personnaliser l'apparence de l'application</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<AppearanceSelect
|
||||
icon={<Palette size={20} />}
|
||||
title="Thème"
|
||||
description="Sélectionner le mode visuel"
|
||||
options={['Clair', 'Sombre', 'Système']}
|
||||
defaultValue="Clair"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<Type size={20} />}
|
||||
title="Taille de la police"
|
||||
description="Ajustez la lisibilité globale de l'interface"
|
||||
options={['Petite', 'Moyenne', 'Grande']}
|
||||
defaultValue="Moyenne"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<Type size={20} />}
|
||||
title="Famille de polices"
|
||||
description="La typographie définit l'âme de l'application"
|
||||
options={['Inter', 'JetBrains Mono', 'Public Sans', 'Outfit']}
|
||||
defaultValue="JetBrains Mono"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<LayoutGrid size={20} />}
|
||||
title="Affichage des notes"
|
||||
description="Gestion visuelle de la grille de composition"
|
||||
options={['Cartes (grille)', 'Liste', 'Tableau']}
|
||||
defaultValue="Cartes (grille)"
|
||||
/>
|
||||
<AppearanceSelect
|
||||
icon={<Maximize size={20} />}
|
||||
title="Taille des notes"
|
||||
description="Structure de la mise en page des éléments"
|
||||
options={['Taille uniforme', 'Variable (Masonry)']}
|
||||
defaultValue="Taille uniforme"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
82
architectural-grid14/src/components/settings/GeneralTab.tsx
Normal file
82
architectural-grid14/src/components/settings/GeneralTab.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { Globe, Bell } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
export const GeneralTab: React.FC = () => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-12"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">Paramètres généraux de l'application</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Langue */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border">
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-base font-bold text-ink">Langue</h4>
|
||||
<p className="text-[11px] text-concrete">Sélectionner une langue</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<select className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-3.5 text-sm outline-none focus:ring-1 ring-blueprint/20 appearance-none cursor-pointer transition-all hover:bg-white dark:hover:bg-black/60 text-ink font-medium">
|
||||
<option>Français</option>
|
||||
<option>English</option>
|
||||
<option>Español</option>
|
||||
</select>
|
||||
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none opacity-40 text-concrete">
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border">
|
||||
<Bell size={18} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-base font-bold text-ink">Notifications</h4>
|
||||
<p className="text-[11px] text-concrete">Gérez vos préférences de notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 divide-y divide-border/40 text-left">
|
||||
<div className="flex items-center justify-between pt-0">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">Notifications par email</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">Recevoir des notifications importantes par email</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-slate"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-ink">Notifications bureau</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">Recevoir des notifications dans votre navigateur</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-slate"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user