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(npm run *)",
|
||||||
"Bash(curl -s http://localhost:3000)",
|
"Bash(curl -s http://localhost:3000)",
|
||||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\" 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