feat: editor improvements and architectural grid prototype
Multiple feature additions and improvements across the application: - NextGen Editor: drag handles, smart paste, block actions - Structured views: Kanban and table layouts for notes - Architectural Grid: new brainstorming/agent interface prototype - Flashcards: SM-2 revision algorithm with AI generation - MCP server: robustness improvements - Graph/PDF chat: fix click propagation and copy behavior - Various UI/UX enhancements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/0c6fb2d9-1b82-4ca3-b0f4-f8373a62faca/0c6fb2d9-1b82-4ca3-b0f4-f8373a62faca.jsonl": 1778182618469,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/137b1f4b-59d9-4ce6-8d74-01f7cbae2ba7/137b1f4b-59d9-4ce6-8d74-01f7cbae2ba7.jsonl": 1778966645519,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/16214191-7091-4aef-a309-f922d351d79f.jsonl": 1779646940751,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/16214191-7091-4aef-a309-f922d351d79f.jsonl": 1779909977869,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/2e0ce74c-a31e-49d8-a0d0-a8b224813533/2e0ce74c-a31e-49d8-a0d0-a8b224813533.jsonl": 1778188935902,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/38000361-5c66-4032-8e1e-ef405e843de0/38000361-5c66-4032-8e1e-ef405e843de0.jsonl": 1778968570815,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/394af47d-c5cd-4cef-bef2-2192717439f8/394af47d-c5cd-4cef-bef2-2192717439f8.jsonl": 1778951280378,
|
||||
@@ -15,6 +15,7 @@
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/92d73875-5939-48fb-9f68-86c88b0f2ff7/92d73875-5939-48fb-9f68-86c88b0f2ff7.jsonl": 1778966017038,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/9902a438-467f-4d57-8f43-28e7d579a95f/9902a438-467f-4d57-8f43-28e7d579a95f.jsonl": 1778839341001,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/a64d78ce-86d3-4ec8-8f79-7589ad05a62c/a64d78ce-86d3-4ec8-8f79-7589ad05a62c.jsonl": 1778846298067,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/a7a904f4-86df-4829-b77e-4beabd9b059e/a7a904f4-86df-4829-b77e-4beabd9b059e.jsonl": 1779649690323,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/af84066e-c0c2-435e-8caf-73037ebf4320/af84066e-c0c2-435e-8caf-73037ebf4320.jsonl": 1779569075175,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/b85430f3-4520-47fd-9b4b-5200ca340a36/b85430f3-4520-47fd-9b4b-5200ca340a36.jsonl": 1779039005865,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ca85061e-6af9-4250-8dc7-9c3bb4839c48/ca85061e-6af9-4250-8dc7-9c3bb4839c48.jsonl": 1778849848444,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"lastRunAtMs": 1779646930753,
|
||||
"turnsSinceLastRun": 6,
|
||||
"lastTranscriptMtimeMs": 1779646930615.2869,
|
||||
"lastProcessedGenerationId": "c2a9fd9d-5b50-42a8-8b17-e414b0be891e",
|
||||
"lastRunAtMs": 1779909959153,
|
||||
"turnsSinceLastRun": 4,
|
||||
"lastTranscriptMtimeMs": 1779909958790.594,
|
||||
"lastProcessedGenerationId": "5bd39ea3-9f17-44d1-8aed-3cc8f3673e97",
|
||||
"trialStartedAtMs": null
|
||||
}
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -5,13 +5,13 @@
|
||||
- Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou).
|
||||
- Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; libellés FR **lisibles** (éviter jargon non expliqué : « wiki », « embed », etc.) et **aide contextuelle** où l'UX l'exige ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle.
|
||||
- Base de données : **INTERDIT TOTALEMENT** de lancer `prisma db push --force-reset`, `prisma migrate reset`, `DROP TABLE`, `TRUNCATE`, `pg_restore` avec clean, ou TOUTE commande qui vide/supprime des données — MÊME SI l'utilisateur est d'accord — sans avoir d'abord : (1) dumpé la base avec `bash /home/devparsa/dev/Momento/dump-db.sh`, (2) vérifié le dump fait au moins 1Mo, (3) obtenu un "OUI" explicite de l'utilisateur. **4 incidents de perte de données documentés (14/05, 15/05 x2, 16/05). NE JAMAIS REFAIRE ÇA.**
|
||||
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles.
|
||||
- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) — similarité sur contenu **représentatif** (pas de troncature arbitraire type 200/800 car.) ; **recherche (sidebar / résultats, ex. flux « ouvrir la note ») et navigation liste des notes** (modes affichage, icônes vs initiales…) : suivre **`SearchModal` et les patterns actuels** dans `architectural-grid`, pas une approximation ou un ancien flux ; **sidebar rail** (`sidebar.tsx`) : une seule icône active ; `activeView` synchronisé avec pathname et query (`/insights`, `/revision`, `/home?reminders=1`) ; panneau latéral contextuel par route (pas la liste carnets sur `/insights` ou Rappels) ; **`/insights` (insights sémantiques)** : suivre **`InsightsView.tsx` + graphe réseau associé dans le prototype** (ex. composition type `NetworkGraph.tsx`) ; **distincte de `/graph`** ; ne pas substituer par une UX « géométrique » décorative ou un regroupement par carnet hors spec prototype ; lorsque données clusters en retard ou partiellement périmées, **montrer l’état dégradé exploitable plutôt qu’une page vide** ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles.
|
||||
- Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global (app **et** extension Web Clipper) ; **Memory Echo** et recherche sémantique doivent fonctionner en persan (RTL, embeddings — pas de contournement « EN only ») ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue.
|
||||
- Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look).
|
||||
- **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu.
|
||||
- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur.
|
||||
- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée).
|
||||
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
|
||||
- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test), **notamment navigation recherche/liste notes et vue `/insights` vs fichiers prototype** — l'utilisateur sanctionne fermement les fausses déclarations ; **ouverture note liée depuis l'éditeur** (ex. bloc live « Ouvrir ») : **split peek inline** animé (`lib/note-peek-sync.ts`, `note-editor-split-peek.tsx` — panneau gauche lecture seule, note courante à droite), **pas nouvel onglet** ; **ne jamais annuler du code non commité** (`git checkout`, reset fichier) **sans demande explicite** (perte de travail documentée, ex. drag handle éditeur) ; en frustration ou pour déléguer, **prévoir des prompts / briefs d'implémentation détaillés** (autre modèle ou dev), en plus des briefs outil de design.
|
||||
- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante (pas d'auto-validation ni d'enchaînement de code non demandé) ; suivi dans `docs/user-stories.md`.
|
||||
- Quand demandé, **fournir des briefs pour un outil de design externe** plutôt que produire les maquettes UX soi-même.
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
- Règles opérationnelles Prisma et sécurité base de données décrites dans `CLAUDE.md` à la racine du repo.
|
||||
- Production : dépôt `/opt/memento` sur `192.168.1.190`, conteneur `memento-note` sur le port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; deploy (`deploy.yaml` / `deploy-prod.sh`) **sans toucher Postgres** (pas de `postgresql-client`, pas de migrations auto en prod).
|
||||
- CI/CD Gitea : `.gitea/workflows/ci.yaml` — CI sur `ubuntu-24.04`, deploy sur runner **`docker-host`** (sur le serveur) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`.
|
||||
- Migrations dans l'image prebuilt : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma` dans le PATH) ; helper `scripts/migrate-docker.sh`.
|
||||
- Vérification deploy : `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et le domaine Cloudflare — purger le cache si versions divergent ; 403 sur `/api/manifest` côté domaine = souvent Cloudflare, pas l'app.
|
||||
- Migrations prebuilt + vérif deploy : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma`) ; helper `scripts/migrate-docker.sh` ; `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et domaine Cloudflare — purger cache si versions divergent ; 403 `/api/manifest` côté domaine = souvent Cloudflare.
|
||||
- Éditeur riche : `rich-text-editor.tsx` — `immediatelyRender: false` ; activer **`shouldRerenderOnTransaction: false`** (quick win perf TipTap 2.5) ; **drag handle / menu bloc** via **`@tiptap/extension-drag-handle-react`** (spec officielle — pas de double plugin `DragHandleExtension` + composant React, pas de repositionnement maison) ; poignée dans **colonne gutter fixe** du wrapper (padding + `getReferencedVirtualElement`), pas sur le bord des listes/numéros ; CSS : **pas `opacity:0` sur `.drag-handle`** (visibilité gérée par le plugin) ; config/callbacks **stables hors composant** ; fondation blocs : `tiptap-unique-id-extension.ts` / **`data-id` persisté à la sauvegarde** (références « Copier la référence ») ; **Smart Paste** : `lib/editor/smart-paste-extension.ts` ; **peek split** note source : `lib/note-peek-sync.ts` + `note-editor-split-peek.tsx` ; epic active `docs/story-nextgen-editor.md` — priorité **PERF > NEXTGEN > UX > MOBILE > MARKDOWN**.
|
||||
- Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`).
|
||||
- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; encore en gap : Living Blocks (`UniqueID` / embeds), Structured Views, graphe knowledge (`GraphKnowledgeMap.tsx`), **insights sémantiques** (`InsightsView.tsx`, **`/insights` ≠ `/graph`**) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev.
|
||||
- Roadmap / écart prototype vs prod : Web Clipper — **`ClipperSimulator.tsx` = référence design uniquement** (pas de simulateur en prod) ; extension **`memento-note/extension/`** v0.3 **Side Panel** (clip page/sélection/lien ; popup Chrome se ferme au clic page — Side Panel pour la sélection) ; i18n extension **15 langues** (`_locales/`, détection locale navigateur ; script `extension/i18n/generate-translations.cjs`) ; **`host_permissions`** incl. LAN ; **URL serveur configurable en dev**, adresse prod figée en release ; cookies/session alignés avec l'instance cible ; **Flashcards IA SM-2 livrées** : `/revision`, `/api/flashcards/*`, génération depuis l'éditeur (GraduationCap) — réf. prototype `RevisionView.tsx` ; **Structured Views partiellement livrées** : schéma par carnet, Table/Kanban, champs partagés et valeurs par note (`/home` + toolbar carnet) — **suivi de tâches par carnet via Kanban structuré** (pas de vue agrégée Notes/Tâches sur la home ; cases à cocher inline dans les notes) ; **Living Blocks partiellement livrées** : `data-id`, Smart Paste, nœud `liveBlock`, détacher/supprimer, peek split — **US-NEXTGEN-EDITOR** en cours (`docs/story-nextgen-editor.md`, **US-TEMPORAL reporté**) ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`), **insights sémantiques** (`InsightsView.tsx`, **`/insights` ≠ `/graph`**) ; publication **Chrome Web Store** : icônes 16/48/128, privacy policy, `host_permissions` prod restreints vs build dev.
|
||||
|
||||
@@ -12,7 +12,13 @@
|
||||
|
||||
- **AC5 anonymousAnalytics DB sync** — La synchronisation de `anonymousAnalytics` vers `UserAISettings` via `updateAISettings()` n'a pas été implémentée. Contrainte utilisateur : zéro écriture DB en 4.1, consentement 100 % client. À implémenter dans une story ultérieure si la cohérence DB devient requise.
|
||||
|
||||
## Deferred from: chart suggestions feature (2026-05-23)
|
||||
## Deferred from: unified tasks view study (2026-05-24)
|
||||
|
||||
- **Vue agrégée Notes/Tâches (Markdown scrape)** — Retirée volontairement (option A produit) : surcharge UX, chevauchement avec vues structurées Kanban. Spec `spec-unified-tasks-view.md` abandonnée ; pas d’unification TipTap/Checklist pour cette vue.
|
||||
|
||||
## Deferred from: US-TEMPORAL product decision (2026-05-24)
|
||||
|
||||
- **Prédictions d'accès temporelles** — Reporté : chevauche rappels + flashcards SM-2 + Memory Echo ; heuristique prototype peu fiable ; `NoteAccessLog` non prioritaire. Voir `docs/user-stories.md` § US-TEMPORAL.
|
||||
|
||||
- **Build error in note-graph-view.tsx** — Variable `plainText` définie plusieurs fois (ligne 238). Fichier préexistant modifié hors de cette tâche. À corriger indépendamment.
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: 'Unified Tasks View — cross-format aggregation study'
|
||||
type: 'feature'
|
||||
created: '2026-05-24'
|
||||
status: 'done' # cancelled — product decision option A (2026-05-24): removed aggregated Tasks view; use Structured Kanban instead
|
||||
context:
|
||||
- '{project-root}/AGENTS.md'
|
||||
- '{project-root}/memento-note/components/notes-list-views.tsx'
|
||||
- '{project-root}/memento-note/lib/types.ts'
|
||||
---
|
||||
|
||||
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||
|
||||
## Intent
|
||||
|
||||
**Problem:** The home **Notes / Tasks** toggle only aggregates Markdown `- [ ]` / `- [x]` lines from `Note.content`. Rich-text TipTap task lists (HTML in `content`) and legacy `checklist` notes (`checkItems` JSON) are invisible in Tasks view, stats column, and sort-by-tasks — causing empty or misleading UX when users create to-dos via the editor or legacy data.
|
||||
|
||||
**Approach:** Introduce a single extraction layer (`lib/tasks/`) that normalizes tasks from all three sources into a stable `UnifiedTask` model with source-specific stable IDs; update toggle/save paths to write back to the correct storage; refresh i18n to explain supported formats without Markdown-only wording.
|
||||
|
||||
## Boundaries & Constraints
|
||||
|
||||
**Always:** No DB schema migration in v1 unless human approves; reuse existing `content` / `checkItems` fields; toggle must use optimistic `updateNote` + `onNotePatch`; all user-facing strings via i18n (EN+FR minimum); no new tests unless explicitly requested; do not conflate Structured Views `checkbox` properties with inline note tasks.
|
||||
|
||||
**Ask First:** Deprecate vs revive `type: checklist` (dedicated note type); one-way migration of legacy `checkItems` → Markdown or TipTap; nested TipTap task support in aggregated view; scope of 15-locale fill vs EN/FR keys only.
|
||||
|
||||
**Never:** Treat Structured View kanban/status as Tasks view; store a parallel tasks table; rewrite entire editor serialization to JSON; run destructive DB commands; claim done without manual verification on markdown + richtext + checklist sample notes.
|
||||
|
||||
## I/O & Edge-Case Matrix
|
||||
|
||||
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|
||||
|----------|--------------|---------------------------|----------------|
|
||||
| Markdown tasks | `type: markdown`, content with `- [ ]` lines | Tasks listed; toggle flips `[ ]`↔`[x]` in content | Skip malformed lines |
|
||||
| TipTap tasks | `type: richtext`, HTML with `ul[data-type="taskList"]` | Tasks listed with text from `<p>` inside `li`; toggle updates `data-checked` + checkbox | Skip items without parseable text |
|
||||
| Checklist note | `type: checklist`, `checkItems: [{id,text,checked}]` | All items listed; toggle updates matching item by `id` | Normalize legacy `done` → `checked` on read |
|
||||
| Mixed note | Markdown lines + TipTap HTML in same richtext note | Both sources extracted (dedupe by normalized text optional v2) | No double-toggle same semantic item in v1 if ambiguous |
|
||||
| Empty scope | Notebook with no extractable tasks | Empty state with format-agnostic hint | No errors |
|
||||
| Toggle race | User toggles in Tasks while note open in editor | Last write wins or editor refresh via `emitNoteChange` | Log + refetch on mismatch |
|
||||
| API update | `PUT /api/notes` with `checkItems` | Same JSON.stringify behavior as server actions | Align API with actions in same PR |
|
||||
|
||||
</frozen-after-approval>
|
||||
|
||||
## Code Map
|
||||
|
||||
- `memento-note/components/notes-list-views.tsx` — current `extractTasksFromNotes`, `getNoteTasksStats`, `handleToggleTask`, Tasks UI
|
||||
- `memento-note/components/home-client.tsx` — Notes/Tasks toggle, passes notes into list views
|
||||
- `memento-note/lib/types.ts` — `CheckItem`, `NoteType`
|
||||
- `memento-note/lib/utils.ts` — `parseNote`, `checkItems` deserialization
|
||||
- `memento-note/components/rich-text-editor.tsx` — TipTap TaskList/TaskItem extensions
|
||||
- `memento-note/components/note-editor/note-editor-context.tsx` — save forces `checkItems: null`; dormant checklist handlers
|
||||
- `memento-note/components/note-checklist.tsx` — checklist UI (legacy card path)
|
||||
- `memento-note/components/note-card.tsx` — checklist toggle via `updateNote({ checkItems })`
|
||||
- `memento-note/app/actions/notes.ts` — canonical server update, JSON.stringify checkItems
|
||||
- `memento-note/app/api/notes/[id]/route.ts` — API checkItems inconsistency
|
||||
- `memento-note/locales/en.json`, `fr.json` — `notes.viewTasks`, `tasksEmptyHint`, etc.
|
||||
- `memento-note/lib/note-preview.ts` — excerpt may leak `[ ]` syntax
|
||||
|
||||
## Tasks & Acceptance
|
||||
|
||||
**Execution (proposed — after study approval):**
|
||||
- [ ] `memento-note/lib/tasks/types.ts` — define `UnifiedTask`, `TaskSource`, stable `taskKey` — single contract for UI
|
||||
- [ ] `memento-note/lib/tasks/extract-tasks.ts` — extract from markdown lines, TipTap HTML (DOMParser or regex+guard), checkItems — replaces inline regex in list views
|
||||
- [ ] `memento-note/lib/tasks/toggle-task.ts` — source-aware write-back to content or checkItems — replaces fragile lineIndex-only toggle
|
||||
- [ ] `memento-note/components/notes-list-views.tsx` — wire extract/toggle helpers; show source badge optional — keep UI, fix data layer
|
||||
- [ ] `memento-note/locales/en.json`, `fr.json` — tasksEmptyHint, tasksHeader, optional `tasksSourceMarkdown` / `tasksSourceRichText` / `tasksSourceChecklist` — honest UX
|
||||
- [ ] `memento-note/app/api/notes/[id]/route.ts` — stringify checkItems like server actions — fix API drift
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Given a markdown note with `- [ ] Buy milk`, when user opens Tasks view for that notebook, then the task appears and toggling persists after reload.
|
||||
- Given a richtext note with TipTap task list in saved HTML, when user opens Tasks view, then tasks appear with correct checked state.
|
||||
- Given a checklist note with `checkItems`, when user opens Tasks view, then items appear and toggle updates `checkItems` not content.
|
||||
- Given empty notebook, when user opens Tasks view, then empty state text does not mention Markdown-only format.
|
||||
- Given user toggles task in aggregated view, when note is reopened, then editor reflects the same checked state.
|
||||
|
||||
## Design Notes
|
||||
|
||||
**Recommended architecture:** pure functions in `lib/tasks/` — no new DB columns. `UnifiedTask` fields: `taskKey`, `noteId`, `noteTitle`, `text`, `completed`, `source: 'markdown'|'tiptap'|'checklist'`, `locator` (lineIndex | domPath | checkItemId).
|
||||
|
||||
**TipTap HTML (observed):** `ul[data-type="taskList"] > li[data-type="taskItem"][data-checked="true|false"]` with label+checkbox and content in nested `div > p`. Toggle: flip `data-checked`, sync `input:checked`, preserve HTML structure.
|
||||
|
||||
**Checklist type decision:** AGENTS.md documents type as legacy/semi-dead. v1 should **read** existing `checkItems` in Tasks view; reviving full checklist editor is a separate story.
|
||||
|
||||
**i18n:** Replace Markdown-only hint with product copy: tasks are collected from checkboxes in notes (lists in rich text, markdown, or checklist notes). Optional short “How it works” link/tooltip.
|
||||
|
||||
**Out of scope v1:** Structured Views checkbox properties; cross-note deduplication; migrating all checklist notes to richtext; nested task hierarchy display.
|
||||
|
||||
## Verification
|
||||
|
||||
**Commands:**
|
||||
- `cd memento-note && npm run lint` — expected: no new errors in touched files
|
||||
|
||||
**Manual checks:**
|
||||
- Create 3 notes in one notebook: markdown with `- [ ]`, richtext with TipTap todo block, import/mock checklist with checkItems — all visible in Tasks view, each toggle survives refresh.
|
||||
9
architectural-grid1/.env.example
Normal file
9
architectural-grid1/.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||
# AI Studio automatically injects this at runtime from user secrets.
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
8
architectural-grid1/.gitignore
vendored
Normal file
8
architectural-grid1/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
24
architectural-grid1/BRAINSTORM_PROMPT.md
Normal file
24
architectural-grid1/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, accent 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-grid1/README.md
Normal file
20
architectural-grid1/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/b7b577c6-4d9f-44ac-8fe1-85bc3c6d6e66
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
13
architectural-grid1/index.html
Normal file
13
architectural-grid1/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Google AI Studio App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6
architectural-grid1/metadata.json
Normal file
6
architectural-grid1/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Architectural Grid",
|
||||
"description": "A minimalist notebook for architectural research and conceptual sketches, featuring a Wave Brainstorming radial canvas for AI-powered idea exploration.",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": []
|
||||
}
|
||||
5508
architectural-grid1/package-lock.json
generated
Normal file
5508
architectural-grid1/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
architectural-grid1/package.json
Normal file
41
architectural-grid1/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-grid1/server.ts
Normal file
193
architectural-grid1/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' || data.type === 'living_block_update') {
|
||||
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();
|
||||
1185
architectural-grid1/src/App.tsx
Normal file
1185
architectural-grid1/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
816
architectural-grid1/src/components/AISidebar.tsx
Normal file
816
architectural-grid1/src/components/AISidebar.tsx
Normal file
@@ -0,0 +1,816 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
FileCode,
|
||||
Globe,
|
||||
Send,
|
||||
Scissors,
|
||||
Zap,
|
||||
Languages,
|
||||
Layout,
|
||||
ArrowRightLeft,
|
||||
BookOpen,
|
||||
History,
|
||||
Target,
|
||||
Network,
|
||||
Clock,
|
||||
AlertCircle
|
||||
} 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[];
|
||||
notes?: Note[];
|
||||
onOpenNote?: (noteId: string) => void;
|
||||
onUpdateNote?: (note: Note) => void;
|
||||
}
|
||||
|
||||
export const AISidebar: React.FC<AISidebarProps> = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
activeNote,
|
||||
aiTab,
|
||||
setAiTab,
|
||||
selectedTone,
|
||||
setSelectedTone,
|
||||
carnets,
|
||||
notes = [],
|
||||
onOpenNote = (_noteId: string) => {},
|
||||
onUpdateNote
|
||||
}) => {
|
||||
const [selectedContextId, setSelectedContextId] = React.useState<string | null>(null);
|
||||
const [hoveredOrbitNode, setHoveredOrbitNode] = React.useState<any | null>(null);
|
||||
|
||||
const explicitWikiLinks = React.useMemo(() => [
|
||||
{ source: 'n1', target: 'n1-b' },
|
||||
{ source: 'n3', target: 'n3-b' },
|
||||
{ source: 'bridge-1', target: 'n1' },
|
||||
{ source: 'bridge-1', target: 'n2' },
|
||||
], []);
|
||||
|
||||
const CARNET_COLOR_PALETTE: { [key: string]: string } = {
|
||||
'1': '#D97706', // Daily Notes - Warm Amber
|
||||
'2': '#059669', // Project: Neo - Soft Emerald
|
||||
'3': '#4F46E5', // Shared Docs - Rich Indigo
|
||||
'4': '#0891B2', // Architecture Research - Clean Cyan
|
||||
'5': '#EA580C', // History of Architecture - Deep Orange
|
||||
'6': '#DB2777', // Modernism - Vibrant Rose
|
||||
'7': '#65A30D', // Sustainable Design - Cool Lime
|
||||
};
|
||||
|
||||
const DEFAULT_CARNET_COLOR = '#71717A';
|
||||
|
||||
const backlinks = React.useMemo(() => {
|
||||
if (!activeNote || !notes) return [];
|
||||
return notes.filter(n => {
|
||||
if (n.id === activeNote.id || n.isDeleted) return false;
|
||||
const isExplicit = explicitWikiLinks.some(link =>
|
||||
(link.source === n.id && link.target === activeNote.id)
|
||||
);
|
||||
const isContentLink = n.content.toLowerCase().includes(`[[${activeNote.title.toLowerCase()}]]`);
|
||||
return isExplicit || isContentLink;
|
||||
});
|
||||
}, [activeNote, notes, explicitWikiLinks]);
|
||||
|
||||
const outboundLinks = React.useMemo(() => {
|
||||
if (!activeNote || !notes) return [];
|
||||
return notes.filter(n => {
|
||||
if (n.id === activeNote.id || n.isDeleted) return false;
|
||||
const isExplicit = explicitWikiLinks.some(link =>
|
||||
(link.source === activeNote.id && link.target === n.id)
|
||||
);
|
||||
const isContentLink = activeNote.content.toLowerCase().includes(`[[${n.title.toLowerCase()}]]`);
|
||||
return isExplicit || isContentLink;
|
||||
});
|
||||
}, [activeNote, notes, explicitWikiLinks]);
|
||||
|
||||
const unlinkedMentions = React.useMemo(() => {
|
||||
if (!activeNote || !notes) return [];
|
||||
return notes.filter(n => {
|
||||
if (n.id === activeNote.id || n.isDeleted) return false;
|
||||
const isLinked = [...backlinks, ...outboundLinks].some(link => link.id === n.id);
|
||||
if (isLinked) return false;
|
||||
return n.content.toLowerCase().includes(activeNote.title.toLowerCase());
|
||||
});
|
||||
}, [activeNote, notes, backlinks, outboundLinks]);
|
||||
|
||||
const orbitNodes = React.useMemo(() => {
|
||||
const list: { id: string; title: string; color: string; carnetName: string; relationship: 'backlink' | 'outbound' | 'mention' }[] = [];
|
||||
|
||||
backlinks.forEach(n => {
|
||||
const carnet = carnets.find(c => c.id === n.carnetId);
|
||||
list.push({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR,
|
||||
carnetName: carnet?.name || 'Carnet',
|
||||
relationship: 'backlink'
|
||||
});
|
||||
});
|
||||
|
||||
outboundLinks.forEach(n => {
|
||||
const carnet = carnets.find(c => c.id === n.carnetId);
|
||||
list.push({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR,
|
||||
carnetName: carnet?.name || 'Carnet',
|
||||
relationship: 'outbound'
|
||||
});
|
||||
});
|
||||
|
||||
unlinkedMentions.forEach(n => {
|
||||
const carnet = carnets.find(c => c.id === n.carnetId);
|
||||
list.push({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR,
|
||||
carnetName: carnet?.name || 'Carnet',
|
||||
relationship: 'mention'
|
||||
});
|
||||
});
|
||||
|
||||
return list.slice(0, 8);
|
||||
}, [backlinks, outboundLinks, unlinkedMentions, carnets]);
|
||||
|
||||
const getSnippetWithHighlight = (content: string, term: string) => {
|
||||
const index = content.toLowerCase().indexOf(term.toLowerCase());
|
||||
if (index === -1) {
|
||||
return <span>{content.substring(0, 80)}...</span>;
|
||||
}
|
||||
const start = Math.max(0, index - 40);
|
||||
const end = Math.min(content.length, index + term.length + 40);
|
||||
const before = content.substring(start, index);
|
||||
const match = content.substring(index, index + term.length);
|
||||
const after = content.substring(index + term.length, end);
|
||||
return (
|
||||
<span>
|
||||
{start > 0 && "..."}
|
||||
{before}
|
||||
<mark className="bg-ochre/20 dark:bg-ochre/40 text-ochre px-1 py-0.5 rounded font-bold">{match}</mark>
|
||||
{after}
|
||||
{end < content.length && "..."}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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-[#FDFCFB] dark:bg-[#0D0D0D] shadow-2xl flex flex-col z-50 shrink-0 relative"
|
||||
>
|
||||
<div className="p-6 border-b border-border/60 space-y-1.5 bg-white/50 dark:bg-black/20 backdrop-blur-md">
|
||||
<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', 'relations'] as AITab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setAiTab(tab)}
|
||||
className={`flex-1 py-3 text-[9px] uppercase tracking-wider font-bold transition-all relative
|
||||
${aiTab === tab ? 'text-manganese' : 'text-muted-ink hover:text-ink/60'}`}
|
||||
>
|
||||
{tab === 'relations' ? 'réseau' : 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="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Contexte</label>
|
||||
<div className="flex items-center gap-1">
|
||||
{(['Professional', 'Creative', 'Academic', 'Casual'] as AITone[]).map((tone) => (
|
||||
<button
|
||||
key={tone}
|
||||
onClick={() => setSelectedTone(tone)}
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center text-[9px] font-bold transition-all border
|
||||
${selectedTone === tone
|
||||
? 'bg-accent text-white border-accent shadow-sm'
|
||||
: 'bg-glass border-border/40 text-muted-ink hover:border-accent/40'}`}
|
||||
title={tone}
|
||||
>
|
||||
{tone.substring(0, 2)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="w-full px-4 py-2.5 bg-white/60 dark:bg-black/40 border border-border rounded-xl text-xs flex items-center justify-between cursor-default group transition-all hover:border-accent/30">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileCode size={14} className="text-accent/60" />
|
||||
<span className="font-medium text-ink">Note Active</span>
|
||||
</div>
|
||||
<div className="text-[8px] bg-accent/5 text-accent/60 px-1.5 py-0.5 rounded-full uppercase font-bold tracking-tighter">Auto</div>
|
||||
</div>
|
||||
|
||||
<HierarchicalCarnetSelector
|
||||
carnets={carnets}
|
||||
selectedId={selectedContextId}
|
||||
onSelect={setSelectedContextId}
|
||||
placeholder="Context supplémentaire..."
|
||||
className="w-full text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-48 flex flex-col items-center justify-center text-center space-y-3 text-muted-ink/30">
|
||||
<div className="w-12 h-12 rounded-full border border-dashed border-muted-ink/10 flex items-center justify-center">
|
||||
<MessageSquare size={18} />
|
||||
</div>
|
||||
<p className="text-[11px] italic leading-relaxed px-12">Conversation prête. Posez votre question ci-dessous.</p>
|
||||
</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-accent/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-accent" />
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-accent">
|
||||
<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-accent/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-accent/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-accent text-paper rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-accent/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 === 'relations' && (
|
||||
<motion.div
|
||||
key="relations"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-6 animate-fadeIn"
|
||||
>
|
||||
<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">Vue Graphe Locale</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
{activeNote ? (
|
||||
<>
|
||||
{/* Interactive local graph SVG container */}
|
||||
<div className="relative p-2 bg-slate-50/50 dark:bg-black/30 border border-border/60 rounded-2xl overflow-hidden shadow-inner flex flex-col items-center">
|
||||
<svg width="100%" height="220" viewBox="0 0 320 220" className="select-none font-sans">
|
||||
<defs>
|
||||
<filter id="glow-panel-sidebar-three" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Dotted circle boundary helper */}
|
||||
<circle cx="160" cy="110" r="70" fill="none" stroke="#E2E8F0" strokeWidth="1" strokeDasharray="3,6" className="dark:stroke-neutral-800" />
|
||||
|
||||
{/* Connections */}
|
||||
{orbitNodes.map((node, i) => {
|
||||
const angle = i * (orbitNodes.length > 0 ? (2 * Math.PI) / orbitNodes.length : 0);
|
||||
const nx = 160 + 70 * Math.cos(angle);
|
||||
const ny = 110 + 62 * Math.sin(angle);
|
||||
return (
|
||||
<g key={node.id}>
|
||||
<line
|
||||
x1="160"
|
||||
y1="110"
|
||||
x2={nx}
|
||||
y2={ny}
|
||||
stroke={node.relationship === 'mention' ? '#94A3B8' : '#A47148'}
|
||||
strokeWidth={node.relationship === 'mention' ? 1.2 : 2}
|
||||
strokeDasharray={node.relationship === 'mention' ? '3,3' : 'none'}
|
||||
className="opacity-50 transition-all hover:opacity-100"
|
||||
/>
|
||||
{node.relationship === 'outbound' && (
|
||||
<polygon
|
||||
points={`${160 + (nx - 160) * 0.75},${110 + (ny - 110) * 0.75} ${160 + (nx - 160) * 0.75 - 4},${110 + (ny - 110) * 0.75 - 4} ${160 + (nx - 160) * 0.75 - 4},${110 + (ny - 110) * 0.75 + 4}`}
|
||||
transform={`rotate(${(angle * 180) / Math.PI}, ${160 + (nx - 160) * 0.75}, ${110 + (ny - 110) * 0.75})`}
|
||||
fill="#A47148"
|
||||
className="opacity-70"
|
||||
/>
|
||||
)}
|
||||
{node.relationship === 'backlink' && (
|
||||
<polygon
|
||||
points={`${160 + (nx - 160) * 0.3},${110 + (ny - 110) * 0.3} ${160 + (nx - 160) * 0.3 - 4},${110 + (ny - 110) * 0.3 - 4} ${160 + (nx - 160) * 0.3 - 4},${110 + (ny - 110) * 0.3 + 4}`}
|
||||
transform={`rotate(${((angle + Math.PI) * 180) / Math.PI}, ${160 + (nx - 160) * 0.3}, ${110 + (ny - 110) * 0.3})`}
|
||||
fill="#A47148"
|
||||
className="opacity-70"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Center node (Active Note) */}
|
||||
<g>
|
||||
<circle
|
||||
cx="160"
|
||||
cy="110"
|
||||
r="15"
|
||||
fill="#A47148"
|
||||
className="stroke-white dark:stroke-black stroke-[3px] shadow transition-transform duration-300 hover:scale-110 active:scale-95 cursor-pointer"
|
||||
/>
|
||||
<circle cx="160" cy="110" r="5" fill="#FFFFFF" />
|
||||
</g>
|
||||
|
||||
{/* Orbit nodes */}
|
||||
{orbitNodes.map((node, i) => {
|
||||
const angle = i * (orbitNodes.length > 0 ? (2 * Math.PI) / orbitNodes.length : 0);
|
||||
const nx = 160 + 70 * Math.cos(angle);
|
||||
const ny = 110 + 62 * Math.sin(angle);
|
||||
const isHovered = hoveredOrbitNode?.id === node.id;
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
className="cursor-pointer group"
|
||||
onClick={() => onOpenNote(node.id)}
|
||||
onMouseEnter={() => setHoveredOrbitNode(node)}
|
||||
onMouseLeave={() => setHoveredOrbitNode(null)}
|
||||
>
|
||||
<circle
|
||||
cx={nx}
|
||||
cy={ny}
|
||||
r={isHovered ? 11 : 8}
|
||||
fill={node.color}
|
||||
stroke={isHovered ? '#000000' : '#FFFFFF'}
|
||||
strokeWidth={1.5}
|
||||
className="transition-all duration-200 group-hover:shadow"
|
||||
/>
|
||||
<text
|
||||
x={nx}
|
||||
y={ny + 15}
|
||||
textAnchor="middle"
|
||||
className="text-[7.5px] font-sans font-bold select-none pointer-events-none fill-ink/70 dark:fill-white/70"
|
||||
>
|
||||
{node.title.length > 10 ? node.title.substring(0, 8) + '...' : node.title}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Interactive local tooltip card info */}
|
||||
<div className="w-full mt-2 bg-white dark:bg-black/40 border border-border/80 rounded-xl p-3 text-xs leading-normal font-sans">
|
||||
{hoveredOrbitNode ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[8px] font-bold uppercase tracking-wide text-muted-ink">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: hoveredOrbitNode.color }} />
|
||||
{hoveredOrbitNode.carnetName}
|
||||
</span>
|
||||
<span className="text-ochre">
|
||||
{hoveredOrbitNode.relationship === 'backlink' ? 'Lien Entrant' : hoveredOrbitNode.relationship === 'outbound' ? 'Lien Sortant' : 'Mention Simple'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-bold text-ink dark:text-white truncate">{hoveredOrbitNode.title}</p>
|
||||
<p className="text-[9px] text-muted-ink italic">Cliquez pour ouvrir la note</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-1 text-muted-ink/60 text-[10px] font-medium leading-normal flex items-center justify-center gap-1.5">
|
||||
<Network size={12} className="text-muted-ink/40" />
|
||||
Survolez un nœud, cliquez pour ouvrir
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lists of backlinks & unlinked mentions */}
|
||||
<div className="space-y-4 pt-2 font-sans">
|
||||
{/* 1. Backlinks */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-[10px] uppercase tracking-[0.18em] font-bold text-muted-ink flex items-center justify-between">
|
||||
<span>Liens Entrans ({backlinks.length})</span>
|
||||
</h5>
|
||||
{backlinks.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[160px] overflow-y-auto custom-scrollbar pr-1">
|
||||
{backlinks.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => onOpenNote(n.id)}
|
||||
className="p-3 bg-white dark:bg-zinc-950 border border-border hover:border-accent/40 rounded-xl cursor-pointer transition-all space-y-1.5 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between text-muted-ink font-sans">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-ink dark:text-white truncate max-w-[180px]">{n.title}</span>
|
||||
<span className="text-[8px] bg-accent/5 text-accent/80 px-1.5 py-0.5 rounded-full font-bold uppercase tracking-tight">Réf</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-ink/70 dark:text-white/70 italic leading-snug">
|
||||
{getSnippetWithHighlight(n.content, activeNote.title)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-ink leading-normal italic bg-slate-50 dark:bg-zinc-900/40 p-3 rounded-xl border border-border/40">Aucun lien entrant explicite pointant vers cette note.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. Outbound Links */}
|
||||
<div className="space-y-2 text-sans">
|
||||
<h5 className="text-[10px] uppercase tracking-[0.18em] font-bold text-muted-ink flex items-center justify-between font-sans">
|
||||
<span>Liens Sortants ({outboundLinks.length})</span>
|
||||
</h5>
|
||||
{outboundLinks.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[160px] overflow-y-auto custom-scrollbar pr-1">
|
||||
{outboundLinks.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => onOpenNote(n.id)}
|
||||
className="p-3 bg-white dark:bg-zinc-950 border border-border hover:border-accent/40 rounded-xl cursor-pointer transition-all space-y-1.5 hover:shadow-sm animate-fadeIn"
|
||||
>
|
||||
<div className="flex items-center justify-between text-muted-ink font-sans font-medium">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-ink dark:text-white truncate max-w-[180px]">{n.title}</span>
|
||||
<span className="text-[8px] bg-indigo-500/10 text-indigo-500 px-1.5 py-0.5 rounded-full font-bold uppercase tracking-tight font-sans">Cible</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-ink/70 dark:text-white/70 italic leading-snug font-sans">
|
||||
{getSnippetWithHighlight(activeNote.content, n.title)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-ink leading-normal italic bg-slate-50 dark:bg-zinc-900/40 p-3 rounded-xl border border-border/40">Cette note ne pointe vers aucun lien sortant explicite.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. Unlinked Mentions */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-[10px] uppercase tracking-[0.18em] font-bold text-muted-ink flex items-center justify-between">
|
||||
<span>Mentions Simples ({unlinkedMentions.length})</span>
|
||||
</h5>
|
||||
{unlinkedMentions.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[160px] overflow-y-auto custom-scrollbar pr-1 font-sans">
|
||||
{unlinkedMentions.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => onOpenNote(n.id)}
|
||||
className="p-3 bg-white dark:bg-zinc-950 border border-border hover:border-accent/40 rounded-xl cursor-pointer transition-all space-y-1.5 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between text-muted-ink">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-ink dark:text-white truncate max-w-[150px]">{n.title}</span>
|
||||
<span className="text-[8px] bg-neutral-100 dark:bg-neutral-800 text-muted-ink px-1.5 py-0.5 rounded-full font-bold uppercase tracking-tight">Mention</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-ink/70 dark:text-white/70 italic leading-snug font-sans">
|
||||
{getSnippetWithHighlight(n.content, activeNote.title)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-ink leading-normal italic bg-slate-50 dark:bg-zinc-900/40 p-3 rounded-xl border border-border/40">Aucune mention textuelle non-liée trouvée dans vos autres notes.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-ink/40">
|
||||
<Network size={36} className="mx-auto mb-3 opacity-30" />
|
||||
<p className="text-xs font-serif italic">Veuillez sélectionner une note pour explorer son graphe relationnel.</p>
|
||||
</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-accent 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-accent 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-accent text-white rounded-xl text-sm font-bold flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-accent/20">
|
||||
<Sparkles size={18} />
|
||||
Générer l'aperçu
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{aiTab === 'discussion' && (
|
||||
<div className="p-6 bg-white/40 dark:bg-black/20 border-t border-border backdrop-blur-xl">
|
||||
<div className="relative group/chat">
|
||||
<textarea
|
||||
rows={5}
|
||||
placeholder="Tapez votre demande ici..."
|
||||
className="w-full bg-white/80 dark:bg-white/5 border border-border rounded-[24px] p-5 pr-14 text-sm outline-none focus:border-accent focus:ring-4 ring-accent/5 transition-all resize-none leading-relaxed font-light shadow-inner"
|
||||
/>
|
||||
<div className="absolute right-4 bottom-4 flex flex-col gap-2">
|
||||
<button className="p-2.5 bg-accent text-white rounded-xl transition-all hover:scale-110 active:scale-95 shadow-lg shadow-accent/20">
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute left-6 bottom-4 flex gap-3 text-muted-ink/40">
|
||||
<button className="hover:text-accent transition-colors"><Globe size={14} /></button>
|
||||
<button className="hover:text-accent transition-colors"><Network size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-4">
|
||||
<p className="text-[9px] text-muted-ink/40 uppercase tracking-[0.3em] font-bold">Shift+Enter for new line</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
396
architectural-grid1/src/components/AgentsView.tsx
Normal file
396
architectural-grid1/src/components/AgentsView.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
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,
|
||||
Menu
|
||||
} 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;
|
||||
onOpenSidebar?: () => void;
|
||||
}
|
||||
|
||||
export const AgentsView: React.FC<AgentsViewProps> = ({
|
||||
selectedAgentId,
|
||||
setSelectedAgentId,
|
||||
carnets,
|
||||
notes,
|
||||
onAddNote,
|
||||
onOpenSidebar
|
||||
}) => {
|
||||
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-6 sm: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 lg:items-end gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onOpenSidebar}
|
||||
className="lg:hidden p-2 -ml-2 text-ink hover:bg-black/5 rounded-lg transition-colors"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl sm: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>
|
||||
</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-accent bg-white shadow-xl shadow-accent/10' : 'border-border bg-white/50 hover:bg-white'}`}
|
||||
>
|
||||
<div className={`p-3 rounded-xl transition-all ${agentType === type.id ? 'bg-accent 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-accent' : 'border-border opacity-20'}`}>
|
||||
{agentType === type.id && <div className="w-2 h-2 bg-accent 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-accent/5 focus:border-accent/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-accent border-accent text-white' : 'bg-white border-border group-hover:border-accent/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-accent 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-accent/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-accent text-white rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:opacity-90 transition-all shadow-xl shadow-accent/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>
|
||||
);
|
||||
};
|
||||
200
architectural-grid1/src/components/AuthPage.tsx
Normal file
200
architectural-grid1/src/components/AuthPage.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Mail, Lock, User, ArrowRight, Github, Globe, Sparkles } from 'lucide-react';
|
||||
|
||||
interface AuthPageProps {
|
||||
onAuthComplete: () => void;
|
||||
onBack: () => void;
|
||||
initialMode?: 'login' | 'register';
|
||||
}
|
||||
|
||||
export const AuthPage: React.FC<AuthPageProps> = ({ onAuthComplete, onBack, initialMode = 'login' }) => {
|
||||
const [mode, setMode] = useState<'login' | 'register'>(initialMode);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
// Simulate auth
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
onAuthComplete();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#FDFCFB] dark:bg-[#0D0D0D] flex flex-col relative overflow-hidden font-sans">
|
||||
{/* Background Orbs */}
|
||||
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-accent/5 blur-[120px] rounded-full" />
|
||||
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-ochre/5 blur-[120px] rounded-full" />
|
||||
|
||||
{/* Header */}
|
||||
<header className="p-8 flex justify-between items-center relative z-10">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-concrete hover:text-ink transition-colors group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full border border-border flex items-center justify-center group-hover:border-accent transition-colors">
|
||||
<Globe size={14} className="group-hover:rotate-12 transition-transform" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Retour</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-ink text-white rounded-xl flex items-center justify-center shadow-lg">
|
||||
<span className="font-serif font-bold text-xl">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight text-ink">Momento</span>
|
||||
</div>
|
||||
|
||||
<div className="w-24" /> {/* Spacer */}
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex items-center justify-center p-6 relative z-10">
|
||||
<div className="w-full max-w-md">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={mode}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="bg-white dark:bg-paper/50 border border-border p-10 rounded-[48px] shadow-2xl relative"
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-serif font-bold text-ink">
|
||||
{mode === 'login' ? 'Bon retour parmi nous' : 'Créer votre espace'}
|
||||
</h1>
|
||||
<p className="text-concrete text-sm font-light">
|
||||
{mode === 'login'
|
||||
? 'Entrez vos identifiants pour accéder à vos notes.'
|
||||
: 'Rejoignez la nouvelle ère de la prise de notes intelligente.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{mode === 'register' && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-concrete px-4">Nom complet</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-concrete group-focus-within:text-accent transition-colors">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Jean Dupont"
|
||||
className="w-full bg-slate-50 dark:bg-white/5 border border-border rounded-2xl py-4 pl-12 pr-4 text-sm outline-none focus:border-accent focus:ring-4 ring-accent/5 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-concrete px-4">Email</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-concrete group-focus-within:text-accent transition-colors">
|
||||
<Mail size={16} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="jean@exemple.com"
|
||||
className="w-full bg-slate-50 dark:bg-white/5 border border-border rounded-2xl py-4 pl-12 pr-4 text-sm outline-none focus:border-accent focus:ring-4 ring-accent/5 transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center px-4">
|
||||
<label className="text-[10px] uppercase tracking-widest font-bold text-concrete">Mot de passe</label>
|
||||
{mode === 'login' && (
|
||||
<button type="button" className="text-[10px] text-accent font-bold uppercase tracking-widest hover:underline">Oublié ?</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-concrete group-focus-within:text-accent transition-colors">
|
||||
<Lock size={16} />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-slate-50 dark:bg-white/5 border border-border rounded-2xl py-4 pl-12 pr-4 text-sm outline-none focus:border-accent focus:ring-4 ring-accent/5 transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-ink text-white py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] flex items-center justify-center gap-3 transition-all hover:shadow-xl hover:shadow-ink/20 active:scale-95 disabled:opacity-50 mt-4 overflow-hidden relative"
|
||||
>
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ repeat: Infinity, duration: 1, ease: 'linear' }}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
{mode === 'login' ? 'Se connecter' : 'Créer mon compte'}
|
||||
<ArrowRight size={14} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-[10px] uppercase tracking-widest font-bold">
|
||||
<span className="bg-white dark:bg-dark-paper px-4 text-concrete">Ou continuer avec</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button className="flex items-center justify-center gap-3 py-3 border border-border rounded-xl hover:bg-slate-50 dark:hover:bg-white/5 transition-all group">
|
||||
<div className="w-4 h-4 bg-white rounded-full flex items-center justify-center">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.1c-.22-.66-.35-1.36-.35-2.1s.13-1.44.35-2.1V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l3.66-2.84z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.66l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z" fill="#EA4335"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink">Google</span>
|
||||
</button>
|
||||
<button className="flex items-center justify-center gap-3 py-3 border border-border rounded-xl hover:bg-slate-50 dark:hover:bg-white/5 transition-all group">
|
||||
<Github size={16} className="text-ink" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink">GitHub</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center pt-4">
|
||||
<p className="text-xs text-concrete">
|
||||
{mode === 'login' ? "Vous n'avez pas de compte ?" : "Vous avez déjà un compte ?"}
|
||||
{' '}
|
||||
<button
|
||||
onClick={() => setMode(mode === 'login' ? 'register' : 'login')}
|
||||
className="text-accent font-bold hover:underline"
|
||||
>
|
||||
{mode === 'login' ? "S'inscrire" : "Se connecter"}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<p className="text-center mt-8 text-[9px] text-concrete font-bold uppercase tracking-[0.3em] opacity-40">
|
||||
© 2024 Momento Labs — Privacy • Terms
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
264
architectural-grid1/src/components/BlockPicker.tsx
Normal file
264
architectural-grid1/src/components/BlockPicker.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react';
|
||||
import { Note, Carnet } from '../types';
|
||||
|
||||
interface BlockPickerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentNote: Note | undefined;
|
||||
allNotes: Note[];
|
||||
carnets: Carnet[];
|
||||
onSelectBlock: (sourceNoteId: string, blockIndex: number) => void;
|
||||
prefilledBlock?: { noteId: string; blockIndex: number } | null;
|
||||
}
|
||||
|
||||
export const BlockPicker: React.FC<BlockPickerProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentNote,
|
||||
allNotes,
|
||||
carnets,
|
||||
onSelectBlock,
|
||||
prefilledBlock
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Extract all paragraphs across notes (exlucing the current note to avoid self-embed)
|
||||
const allBlocks = useMemo(() => {
|
||||
const list: Array<{
|
||||
id: string;
|
||||
noteId: string;
|
||||
noteTitle: string;
|
||||
carnetName: string;
|
||||
blockIndex: number;
|
||||
text: string;
|
||||
snippet: string;
|
||||
}> = [];
|
||||
|
||||
allNotes.forEach(note => {
|
||||
if (currentNote && note.id === currentNote.id) return;
|
||||
|
||||
const paragraphs = note.content.split('\n');
|
||||
paragraphs.forEach((p, idx) => {
|
||||
const text = p.trim();
|
||||
// Skip empty lines, headings, or short snippets
|
||||
if (text.length < 20 || text.startsWith('#') || text.startsWith('[[living-block')) return;
|
||||
|
||||
// Find carnet
|
||||
const carnet = carnets.find(c => c.id === note.carnetId);
|
||||
|
||||
// 30-word snippet
|
||||
const words = text.split(/\s+/);
|
||||
const snippet = words.slice(0, 30).join(' ') + (words.length > 30 ? '...' : '');
|
||||
|
||||
list.push({
|
||||
id: `${note.id}-${idx}`,
|
||||
noteId: note.id,
|
||||
noteTitle: note.title || 'Untitled',
|
||||
carnetName: carnet?.name || 'Général',
|
||||
blockIndex: idx,
|
||||
text,
|
||||
snippet
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return list;
|
||||
}, [allNotes, currentNote, carnets]);
|
||||
|
||||
// Jaccard similarity helper for AI Recommendations
|
||||
const calculateSimilarity = (textA: string, textB: string): number => {
|
||||
const getWords = (str: string) => new Set(str.toLowerCase().replace(/[.,\/#!$%\^&\*;:{}=\-_`~()?"']/g,"").split(/\s+/).filter(w => w.length > 3));
|
||||
const wordsA = getWords(textA);
|
||||
const wordsB = getWords(textB);
|
||||
|
||||
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
||||
|
||||
let intersection = 0;
|
||||
wordsA.forEach(w => {
|
||||
if (wordsB.has(w)) intersection++;
|
||||
});
|
||||
|
||||
const union = wordsA.size + wordsB.size - intersection;
|
||||
return intersection / union;
|
||||
};
|
||||
|
||||
// Compile recommendations
|
||||
const blockSuggestions = useMemo(() => {
|
||||
if (!currentNote) return [];
|
||||
|
||||
return allBlocks.map(block => {
|
||||
const baseSim = calculateSimilarity(currentNote.content + " " + currentNote.title, block.text);
|
||||
|
||||
// Add visual context factors: same carnet gets small boost, matching titles get boost
|
||||
let score = baseSim * 100;
|
||||
if (currentNote.carnetId === allNotes.find(n => n.id === block.noteId)?.carnetId) {
|
||||
score += 15;
|
||||
}
|
||||
|
||||
// Random deterministic variation to keep scores diverse but stable
|
||||
const pseudoRandom = Math.abs(Math.sin(block.blockIndex + block.noteId.charCodeAt(0))) * 12;
|
||||
score = Math.min(94, Math.max(52, score + pseudoRandom));
|
||||
|
||||
return {
|
||||
...block,
|
||||
score: Math.round(score)
|
||||
};
|
||||
}).sort((a, b) => b.score - a.score);
|
||||
}, [allBlocks, currentNote, allNotes]);
|
||||
|
||||
|
||||
// Compile search results
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchQuery) return allBlocks;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allBlocks.filter(block =>
|
||||
block.text.toLowerCase().includes(query) ||
|
||||
block.noteTitle.toLowerCase().includes(query)
|
||||
);
|
||||
}, [allBlocks, searchQuery]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-ink/30 dark:bg-black/40 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||
className="w-[480px] max-w-full bg-slate-50/90 dark:bg-zinc-900/90 backdrop-blur-md rounded-2xl border border-[#D5D2CD] dark:border-neutral-800 shadow-2xl flex flex-col max-h-[85vh] overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-[#D5D2CD]/60 dark:border-neutral-800/60 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500">
|
||||
<Link2 size={15} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink dark:text-dark-ink font-serif">Living Block Picker</h3>
|
||||
<p className="text-[10px] text-concrete font-medium uppercase tracking-widest">Connecter un bloc en temps réel</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-full text-concrete transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Selection */}
|
||||
<div className="flex border-b border-[#D5D2CD]/40 dark:border-neutral-800/40 px-3 bg-black/[0.01]">
|
||||
<button
|
||||
onClick={() => setActiveTab('suggestions')}
|
||||
className={`flex-1 py-3 text-[10px] uppercase tracking-[0.15em] font-extrabold transition-all relative
|
||||
${activeTab === 'suggestions' ? 'text-blue-600 dark:text-blue-400 font-black' : 'text-concrete hover:text-ink/70'}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Sparkles size={11} />
|
||||
Suggestions IA
|
||||
</span>
|
||||
{activeTab === 'suggestions' && (
|
||||
<motion.div layoutId="pickerTab" className="absolute bottom-0 left-0 right-0 h-[2px] bg-blue-500" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('search')}
|
||||
className={`flex-1 py-3 text-[10px] uppercase tracking-[0.15em] font-extrabold transition-all relative
|
||||
${activeTab === 'search' ? 'text-blue-600 dark:text-blue-400 font-black' : 'text-concrete hover:text-ink/70'}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Search size={11} />
|
||||
Rechercher
|
||||
</span>
|
||||
{activeTab === 'search' && (
|
||||
<motion.div layoutId="pickerTab" className="absolute bottom-0 left-0 right-0 h-[2px] bg-blue-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Input Box */}
|
||||
{activeTab === 'search' && (
|
||||
<div className="p-3 border-b border-[#D5D2CD]/40 dark:border-neutral-800/40 bg-white/40 dark:bg-zinc-950/20">
|
||||
<div className="relative flex items-center">
|
||||
<Search size={14} className="absolute left-3.5 text-concrete pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher un extrait de note..."
|
||||
className="w-full bg-white dark:bg-zinc-850 border border-[#D5D2CD] dark:border-neutral-800 rounded-xl pl-9 pr-4 py-2 text-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-all font-sans"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main List */}
|
||||
<div className="flex-1 overflow-y-auto p-3.5 custom-scrollbar space-y-2">
|
||||
{activeTab === 'suggestions' ? (
|
||||
blockSuggestions.length > 0 ? (
|
||||
blockSuggestions.map(block => (
|
||||
<button
|
||||
key={block.id}
|
||||
onClick={() => onSelectBlock(block.noteId, block.blockIndex)}
|
||||
className="w-full text-left p-3 rounded-xl border border-transparent hover:border-black/[0.08] hover:bg-white/70 dark:hover:bg-zinc-800/50 bg-white/30 dark:bg-zinc-800/10 transition-all group relative flex gap-3.5"
|
||||
>
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<p className="font-serif italic text-[13px] leading-relaxed text-ink/90 dark:text-dark-ink group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||
« {block.snippet} »
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-[10px] text-concrete font-medium">
|
||||
<span className="truncate max-w-[150px] font-semibold">{block.noteTitle}</span>
|
||||
<span className="opacity-40">•</span>
|
||||
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider bg-black/5 dark:bg-white/5 py-0.5 px-1.5 rounded text-[8px]">
|
||||
<Folder size={10} className="opacity-60" /> {block.carnetName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discrete Percentage Circle Score */}
|
||||
<div className="shrink-0 flex flex-col justify-center items-end">
|
||||
<span className="text-[10px] font-mono tracking-tighter bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-2 py-0.5 rounded-full border border-blue-500/10">
|
||||
{block.score}% d'affinité
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-concrete italic text-xs">
|
||||
Aucune note complémentaire disponible pour suggérer un bloc.
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
searchResults.length > 0 ? (
|
||||
searchResults.map(block => (
|
||||
<button
|
||||
key={block.id}
|
||||
onClick={() => onSelectBlock(block.noteId, block.blockIndex)}
|
||||
className="w-full text-left p-3 rounded-xl border border-transparent hover:border-black/[0.08] hover:bg-white/70 dark:hover:bg-zinc-800/50 bg-white/30 dark:bg-zinc-800/10 transition-all group flex flex-col gap-1.5"
|
||||
>
|
||||
<p className="font-serif italic text-[13px] leading-relaxed text-ink/90 dark:text-dark-ink">
|
||||
« {block.text} »
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-[10px] text-concrete font-medium w-full">
|
||||
<span>Source : <strong className="text-ink/70">{block.noteTitle}</strong></span>
|
||||
<span className="text-[9px] uppercase tracking-wider bg-black/5 dark:bg-white/5 py-0.5 px-1.5 rounded">
|
||||
{block.carnetName}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-concrete italic text-xs">
|
||||
Aucun bloc ne correspond à votre recherche.
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</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-accent/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-accent/40 hover:bg-accent/5 transition-all group disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
<FileText size={24} className="text-concrete group-hover:text-accent 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>
|
||||
);
|
||||
};
|
||||
348
architectural-grid1/src/components/BrainstormView/WaveCanvas.tsx
Normal file
348
architectural-grid1/src/components/BrainstormView/WaveCanvas.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
618
architectural-grid1/src/components/ClipperSimulator.tsx
Normal file
618
architectural-grid1/src/components/ClipperSimulator.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
X,
|
||||
Lock,
|
||||
RefreshCw,
|
||||
Chrome,
|
||||
Check,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Sparkles,
|
||||
ArrowUpRight,
|
||||
AlertTriangle,
|
||||
Globe,
|
||||
Scissors,
|
||||
Bookmark
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Carnet, Note, Tag } from '../types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface ClipperSimulatorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
carnets: Carnet[];
|
||||
activeCarnetId: string;
|
||||
onAddNote: (note: Note) => void;
|
||||
onTriggerToast: (title: string, noteId: string) => void;
|
||||
}
|
||||
|
||||
interface MockArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
domain: string;
|
||||
url: string;
|
||||
favicon: string;
|
||||
content: string[];
|
||||
suggestedTags: string[];
|
||||
aiGeneratedTitle: string;
|
||||
aiSummary: string;
|
||||
}
|
||||
|
||||
const MOCK_ARTICLES: MockArticle[] = [
|
||||
{
|
||||
id: 'art-1',
|
||||
title: 'The Bauhaus Theory & Functional Spatial Systems',
|
||||
domain: 'bauhausstudios.org',
|
||||
url: 'https://bauhausstudios.org/theory/functionalism',
|
||||
favicon: 'https://www.google.com/s2/favicons?domain=bauhausstudios.org&sz=64',
|
||||
content: [
|
||||
'Functionalist design operates on the direct correlation between physical geometry and spatial behavior. At the Bauhaus, teachers like Walter Gropius and Hannes Meyer postulated that an architectural object should serve its function strictly, discarding superfluous details that obscure the purity of its skeleton.',
|
||||
'The modern grid represents an honest commitment to industrial standardization. By segmenting living and working spaces into predictable, modular blocks, architects can optimize solar gain, human traffic flows, and construction material metrics.',
|
||||
'Light is the ultimate deconstructive asset within functional systems. When light pierces the rigid geometry of a modernist envelope, it shifts the perceived density of structural grids, transforming cold static steel interfaces into canvas-like elements that respond dynamically to local chronologies.'
|
||||
],
|
||||
suggestedTags: ['Bauhaus', 'Functionalism', 'Spatial Design', 'German Modernism'],
|
||||
aiGeneratedTitle: 'Bauhaus Functionalism & Rhythmic Grid Logic',
|
||||
aiSummary: 'An exploration of how Walter Gropius and Bauhaus theorists utilized geometric grids and deconstructive light to align architectural materiality with industrial standardization and human behavioral workflows.'
|
||||
},
|
||||
{
|
||||
id: 'art-2',
|
||||
title: 'Sustainable Wood Frameworks & Decarbonized Structures',
|
||||
domain: 'ecotimber.com',
|
||||
url: 'https://ecotimber.com/future/timber',
|
||||
favicon: 'https://www.google.com/s2/favicons?domain=ecotimber.com&sz=64',
|
||||
content: [
|
||||
'Decarbonizing global real estate requires replacing portland cement and heavy structural steel with cross-laminated timber (CLT). CLT stands as a highly predictable engineered wood structure that sequesters atmospheric carbon dioxide directly inside the load-bearing framework of high-density buildings.',
|
||||
'Integrating CLT with parametric optimization allows for maximum material efficiency. Architects slice wood beams along precise stress lines generated by finite element analysis solvers, removing empty material volumes while keeping the building safe, functional, and durable.',
|
||||
'Passive solar energy design matches this structural honesty perfectly. By positioning CLT mass in the interior core, the building acts as a solar battery, absorbing raw passive light energy during peak hours and radiating warmth throughout the cold seasonal nights.'
|
||||
],
|
||||
suggestedTags: ['Sustainabilty', 'Ecology', 'CLT Material', 'Decarbonization'],
|
||||
aiGeneratedTitle: 'CLT Systems & Carbon-Neutral Frameworks',
|
||||
aiSummary: 'A breakdown of high-density cross-laminated timber (CLT) integration, using parametric simulation to optimize stress distribution and passive thermal retention for modern sustainable spaces.'
|
||||
}
|
||||
];
|
||||
|
||||
export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
carnets,
|
||||
activeCarnetId,
|
||||
onAddNote,
|
||||
onTriggerToast,
|
||||
}) => {
|
||||
const [activeArticleIdx, setActiveArticleIdx] = useState(0);
|
||||
const activeArticle = MOCK_ARTICLES[activeArticleIdx];
|
||||
|
||||
// Clipper Extension Popup States
|
||||
const [selectedCarnetId, setSelectedCarnetId] = useState(activeCarnetId || carnets[0]?.id || '1');
|
||||
const [selectedText, setSelectedText] = useState('');
|
||||
const [clipperState, setClipperState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [aiGeneratedTitle, setAiGeneratedTitle] = useState('');
|
||||
const [lastCreatedNoteId, setLastCreatedNoteId] = useState('');
|
||||
const [customError, setCustomError] = useState('');
|
||||
|
||||
// Dropdown UI
|
||||
const [showCarnetDropdown, setShowCarnetDropdown] = useState(false);
|
||||
|
||||
// Monitor text selections in the mock web page content
|
||||
const handleTextSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().trim().length > 0) {
|
||||
const text = selection.toString().trim();
|
||||
// Ensure the text belongs to our mock article content
|
||||
setSelectedText(text);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear selections
|
||||
const clearSelection = () => {
|
||||
setSelectedText('');
|
||||
window.getSelection()?.removeAllRanges();
|
||||
};
|
||||
|
||||
// Preset highlights to make it easy to select text without highlighting with mouse
|
||||
const handlePresetHighlight = (paragraph: string) => {
|
||||
setSelectedText(paragraph);
|
||||
};
|
||||
|
||||
// Handle the Clipper Action
|
||||
const handleClip = (type: 'page' | 'selection') => {
|
||||
setClipperState('loading');
|
||||
|
||||
// Simulate AI extraction and processing (summary, tags generation)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// Occasional simulated error for retry demonstration
|
||||
if (Math.random() < 0.15) {
|
||||
throw new Error("Connexion réseau interrompue. L'extension n'a pas pu joindre les serveurs Momento.");
|
||||
}
|
||||
|
||||
const dateStr = new Date().toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
const generatedTags: Tag[] = activeArticle.suggestedTags.map((tagLabel, idx) => ({
|
||||
id: `t-clip-${Date.now()}-${idx}`,
|
||||
label: tagLabel,
|
||||
type: 'ai'
|
||||
}));
|
||||
|
||||
const newNoteId = `n-clip-${Date.now()}`;
|
||||
|
||||
let clipTitle = activeArticle.aiGeneratedTitle;
|
||||
let clipContent = '';
|
||||
|
||||
if (type === 'selection' && selectedText) {
|
||||
clipTitle = `Capture : ${activeArticle.title.substring(0, 30)}...`;
|
||||
clipContent = `**[Sélection capturée]**\n\n> ${selectedText}\n\n---\n\n**Contexte initial :** ${activeArticle.aiSummary}\n\nURL Source : ${activeArticle.url}`;
|
||||
} else {
|
||||
clipContent = `**[Page web complète clippée]**\n\n**Résumé généré par l'IA :**\n${activeArticle.aiSummary}\n\n---\n\n**Contenu de l'article :**\n\n${activeArticle.content.join('\n\n')}\n\nURL Source : ${activeArticle.url}`;
|
||||
}
|
||||
|
||||
const newNote: Note = {
|
||||
id: newNoteId,
|
||||
carnetId: selectedCarnetId,
|
||||
title: clipTitle,
|
||||
content: clipContent,
|
||||
imageUrl: activeArticleIdx === 0
|
||||
? 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600'
|
||||
: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600',
|
||||
date: dateStr,
|
||||
tags: [
|
||||
...generatedTags,
|
||||
{ id: 't-web-source', label: 'Clipped', type: 'user' }
|
||||
],
|
||||
// Custom clipper details
|
||||
isClipped: true,
|
||||
clipSourceUrl: activeArticle.url,
|
||||
clipFavicon: activeArticle.favicon,
|
||||
clipDate: dateStr
|
||||
};
|
||||
|
||||
setAiGeneratedTitle(clipTitle);
|
||||
setLastCreatedNoteId(newNoteId);
|
||||
setClipperState('success');
|
||||
|
||||
// Add note to Momento Database
|
||||
onAddNote(newNote);
|
||||
|
||||
// Fire real-time notification toast in Momento!
|
||||
onTriggerToast(clipTitle, newNoteId);
|
||||
|
||||
} catch (err: any) {
|
||||
setCustomError(err.message || "Erreur de connexion.");
|
||||
setClipperState('error');
|
||||
}
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleResetClipper = () => {
|
||||
setClipperState('idle');
|
||||
setCustomError('');
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 backdrop-blur-md p-4 sm:p-6 overflow-y-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, y: 10 }}
|
||||
className="w-full max-w-6xl h-[85vh] bg-paper dark:bg-dark-paper border border-border rounded-[24px] shadow-2xl flex flex-col md:flex-row overflow-hidden"
|
||||
>
|
||||
{/* Left column: Realistic Mock Browser Page */}
|
||||
<div className="flex-1 flex flex-col bg-slate-50 dark:bg-black/10 border-r border-border overflow-hidden">
|
||||
{/* Mock Browser Header */}
|
||||
<div className="bg-white dark:bg-dark-paper border-b border-border px-4 py-3 flex items-center gap-3">
|
||||
{/* Window Controls */}
|
||||
<div className="flex gap-1.5 mr-2">
|
||||
<button onClick={onClose} className="w-3 h-3 rounded-full bg-red-400 hover:bg-red-500 transition-colors" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5 max-w-[400px]">
|
||||
{MOCK_ARTICLES.map((art, idx) => (
|
||||
<button
|
||||
key={art.id}
|
||||
onClick={() => {
|
||||
setActiveArticleIdx(idx);
|
||||
clearSelection();
|
||||
handleResetClipper();
|
||||
}}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg font-medium flex items-center gap-2 max-w-[170px] truncate transition-colors
|
||||
${activeArticleIdx === idx
|
||||
? 'bg-slate-100 dark:bg-white/5 text-ink dark:text-dark-ink border border-border'
|
||||
: 'text-concrete hover:bg-slate-50 dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<img src={art.favicon} alt="" className="w-3.5 h-3.5 object-contain" onError={(e) => { (e.target as any).src = 'https://www.google.com/s2/favicons?domain=google.com'; }} />
|
||||
<span className="truncate">{art.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Live Indicator of Clipper Simulator */}
|
||||
<div className="ml-auto hidden sm:flex items-center gap-2 bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 text-[10px] font-bold tracking-widest uppercase px-3 py-1 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-cyan-500 animate-pulse" />
|
||||
Simulateur de Capture
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Browser Address Bar */}
|
||||
<div className="bg-white dark:bg-dark-paper border-b border-border px-4 py-2 flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-concrete">
|
||||
<button className="p-1 hover:bg-slate-100 dark:hover:bg-white/5 rounded"><ArrowUpRight className="rotate-270" size={14} /></button>
|
||||
<button className="p-1 hover:bg-slate-100 dark:hover:bg-white/5 rounded" disabled><ArrowUpRight className="rotate-90" size={14} /></button>
|
||||
<button onClick={() => { clearSelection(); handleResetClipper(); }} className="p-1 hover:bg-slate-100 dark:hover:bg-white/5 rounded"><RefreshCw size={13} /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-slate-50 dark:bg-white/5 border border-border px-3 py-1.5 rounded-lg flex items-center gap-2 text-xs text-concrete">
|
||||
<Lock size={12} className="text-emerald-500" />
|
||||
<span className="text-emerald-600 font-medium select-none">https://</span>
|
||||
<span className="text-ink dark:text-dark-ink font-light select-all">{activeArticle.domain}</span>
|
||||
<span className="text-concrete/60 select-all">{activeArticle.url.slice(activeArticle.url.indexOf(activeArticle.domain) + activeArticle.domain.length)}</span>
|
||||
</div>
|
||||
|
||||
{/* Web Extension active badge */}
|
||||
<button
|
||||
className="p-1.5 bg-accent/10 border border-accent/20 rounded-lg text-accent animate-pulse relative group"
|
||||
title="Momento Web Clipper is active"
|
||||
>
|
||||
<Scissors size={14} className="-rotate-90" />
|
||||
<span className="absolute bottom-full right-0 mb-2 whitespace-nowrap hidden group-hover:block bg-ink text-paper text-[10px] py-1 px-2 rounded-md shadow-lg">
|
||||
Extension active sur cette page
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Web Viewport */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto bg-white p-6 sm:p-10 select-text dark:bg-zinc-950 dark:text-zinc-200"
|
||||
onMouseUp={handleTextSelection}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-400 dark:text-neutral-500 uppercase tracking-wider font-semibold">
|
||||
<Globe size={12} />
|
||||
<span>Publié sur {activeArticle.domain}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl font-serif font-bold text-neutral-900 dark:text-neutral-50 leading-tight">
|
||||
{activeArticle.title}
|
||||
</h1>
|
||||
|
||||
<div className="border-y border-neutral-100 dark:border-zinc-800 py-3 flex items-center justify-between text-xs text-neutral-400">
|
||||
<span className="font-mono">Date : Capture Temps Réel</span>
|
||||
<span className="italic">Sélectionnez du texte ci-dessous pour le clipper</span>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="bg-sky-50 dark:bg-sky-950/20 p-4 rounded-xl border border-sky-100 dark:border-sky-950/50 space-y-2">
|
||||
<p className="text-xs text-sky-800 dark:text-sky-300 font-semibold flex items-center gap-2">
|
||||
<Sparkles size={13} className="text-sky-500" />
|
||||
Piste d'évaluation :
|
||||
</p>
|
||||
<p className="text-xs text-sky-700/80 dark:text-sky-400/80 leading-relaxed">
|
||||
Survolez et <strong>surlignez n'importe quel texte</strong> à la souris dans l'article ci-dessous pour activer instantanément l'état <em>Sélection active</em> dans l'extension ! Vous pouvez aussi cliquer sur un paragraphe pour le simuler :
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Content paragraphs */}
|
||||
<div className="space-y-6 text-neutral-700 dark:text-zinc-300 leading-relaxed font-serif text-base">
|
||||
{activeArticle.content.map((p, index) => {
|
||||
const isParaSelected = selectedText === p;
|
||||
return (
|
||||
<p
|
||||
key={index}
|
||||
onClick={() => handlePresetHighlight(p)}
|
||||
className={`cursor-pointer transition-all duration-300 p-2.5 rounded-lg border
|
||||
${isParaSelected
|
||||
? 'bg-accent/10 border-accent text-neutral-900 dark:text-white font-medium scale-[1.01] shadow-sm'
|
||||
: 'border-transparent hover:bg-neutral-50 dark:hover:bg-neutral-900'}`}
|
||||
title="Cliquer pour sélectionner ce paragraphe"
|
||||
>
|
||||
{p}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedText && (
|
||||
<div className="pt-4 flex items-center justify-between border-t border-neutral-100 dark:border-zinc-800">
|
||||
<div className="text-xs text-accent font-medium flex items-center gap-1">
|
||||
<Check size={12} />
|
||||
<span>Sélection enregistrée ({selectedText.split(' ').length} mots)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="text-xs text-concrete hover:underline"
|
||||
>
|
||||
Effacer la sélection
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: Simulated Browser Extension Popup Screen (Exactly 400x520px envelope styled elegantly) */}
|
||||
<div className="w-full md:w-[420px] bg-slate-100 dark:bg-zinc-900 p-6 flex items-center justify-center border-t md:border-t-0 md:border-l border-border relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 text-concrete hover:text-ink hover:bg-neutral-200 dark:hover:bg-zinc-800 transition-colors rounded-full"
|
||||
title="Quitter le simulateur"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* Explicitly designed container mimicking browser overlay/extension dropdown at 400x520px target size */}
|
||||
<div
|
||||
id="clipper-extension-popup"
|
||||
className="w-full max-w-[400px] h-[520px] bg-white dark:bg-neutral-950 rounded-2xl shadow-2xl border border-neutral-200 dark:border-neutral-800 flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Extension Hub Header */}
|
||||
<header className="px-5 py-4 border-b border-neutral-100 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/40 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Momento Logo with Clipper Branding */}
|
||||
<div className="w-7 h-7 bg-ink text-paper rounded-lg flex items-center justify-center font-serif font-black text-sm">
|
||||
M
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<span className="text-xs font-bold font-serif text-ink dark:text-dark-ink tracking-tight">Momento</span>
|
||||
<span className="text-[10px] text-accent block font-mono font-medium tracking-widest uppercase">Web Clipper</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-[9.5px] font-bold text-neutral-400 uppercase tracking-widest leading-none">Connecté</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Popup Dynamic Content Screen (Based on Clipper States) */}
|
||||
<div className="flex-1 p-5 flex flex-col justify-between overflow-y-auto">
|
||||
|
||||
{/* STATE: IDLE or SELECTED */}
|
||||
{clipperState === 'idle' && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Destination Selection with styling from the design guideline prompt */}
|
||||
<div>
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-concrete block mb-1.5">
|
||||
Carnet de destination
|
||||
</label>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCarnetDropdown(!showCarnetDropdown)}
|
||||
className="w-full px-3 py-2.5 bg-neutral-50 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 hover:border-accent rounded-lg text-xs font-medium text-ink dark:text-dark-ink flex items-center justify-between transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 rounded-md bg-accent/10 text-accent flex items-center justify-center text-[9px] font-bold font-serif">
|
||||
{carnets.find(c => c.id === selectedCarnetId)?.initial || 'N'}
|
||||
</span>
|
||||
{carnets.find(c => c.id === selectedCarnetId)?.name || 'Sélectionner un carnet'}
|
||||
</span>
|
||||
<ChevronDown size={14} className="text-concrete" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showCarnetDropdown && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="absolute left-0 right-0 mt-1.5 z-50 max-h-[160px] overflow-y-auto bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg shadow-xl"
|
||||
>
|
||||
{carnets.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => {
|
||||
setSelectedCarnetId(c.id);
|
||||
setShowCarnetDropdown(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-xs text-ink dark:text-dark-ink hover:bg-neutral-50 dark:hover:bg-neutral-800 flex items-center gap-2 transition-colors border-b border-neutral-50 dark:border-neutral-800/20 last:border-0"
|
||||
>
|
||||
<span className="w-4 h-4 rounded bg-neutral-100 dark:bg-neutral-800 text-concrete flex items-center justify-center text-[9px] font-bold">
|
||||
{c.initial}
|
||||
</span>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section of active webpage info */}
|
||||
<div className="p-3 border border-neutral-100 dark:border-neutral-800/80 rounded-xl bg-neutral-50/50 dark:bg-neutral-900/20 space-y-1.5">
|
||||
<span className="text-[9px] uppercase font-bold tracking-widest text-concrete">Page active</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={activeArticle.favicon} alt="" className="w-4.5 h-4.5 rounded object-contain" />
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-xs font-bold text-ink dark:text-dark-ink truncate">{activeArticle.title}</p>
|
||||
<p className="text-[10px] text-concrete truncate">{activeArticle.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STATE: ACTIVE SELECTION PREVIEW (Triggered when user highlights text) */}
|
||||
<AnimatePresence>
|
||||
{selectedText ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="p-3.5 border border-sky-100 dark:border-sky-950 bg-sky-500/5 dark:bg-sky-500/10 rounded-xl space-y-2"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-sky-600 dark:text-sky-400 uppercase tracking-wider flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-sky-500 animate-pulse" />
|
||||
Sélection détectée
|
||||
</span>
|
||||
<button onClick={clearSelection} className="text-[10px] text-concrete hover:text-ink">
|
||||
ignorer
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-ink/80 dark:text-dark-ink/80 italic leading-relaxed line-clamp-3 pl-2 border-l-2 border-sky-400">
|
||||
「 {selectedText} 」
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="p-4 border border-dashed border-neutral-200 dark:border-neutral-800 rounded-xl text-center">
|
||||
<p className="text-xs text-concrete leading-normal">
|
||||
Astuce : surlignez du texte à l'écran pour clipper une sélection précise de la page en tant que note.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Buttons logic */}
|
||||
<div className="flex flex-col gap-2.5 pt-4">
|
||||
{selectedText && (
|
||||
<button
|
||||
onClick={() => handleClip('selection')}
|
||||
style={{ id: 'btn-clip-sel' }}
|
||||
className="py-3 px-4 bg-sky-600 hover:bg-sky-700 text-white rounded-xl text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-2 shadow-lg shadow-sky-600/10 transition-all scale-100 active:scale-95"
|
||||
>
|
||||
<Scissors size={14} className="-rotate-90" />
|
||||
Clipper la sélection
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleClip('page')}
|
||||
style={{ id: 'btn-clip-page' }}
|
||||
className={`py-3.5 px-4 rounded-xl text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-2 transition-all active:scale-95
|
||||
${selectedText
|
||||
? 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-zinc-300 dark:hover:bg-neutral-800'
|
||||
: 'bg-ink text-paper hover:opacity-95 shadow-xl shadow-black/10'}`}
|
||||
>
|
||||
<Bookmark size={14} className="fill-current" />
|
||||
Clipper cette page
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* STATE: LOADING (Traitement AI, embedding & categorisation) */}
|
||||
{clipperState === 'loading' && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center space-y-4">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="w-12 h-12 rounded-full border border-neutral-100 dark:border-neutral-800 animate-ping absolute" />
|
||||
<Loader2 size={36} className="animate-spin text-accent" />
|
||||
</div>
|
||||
<div className="text-center space-y-1.5 pt-2">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-concrete">
|
||||
Analyse de la source
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-ink dark:text-dark-ink animate-pulse">
|
||||
Traitement en cours…
|
||||
</p>
|
||||
<p className="text-[10px] text-concrete max-w-[240px] leading-relaxed mx-auto">
|
||||
Génération automatique des tags, résumé sémantique & calcul des embeddings en cours.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STATE: SUCCESS */}
|
||||
{clipperState === 'success' && (
|
||||
<div className="flex-1 flex flex-col justify-between py-2">
|
||||
<div className="flex-1 flex flex-col items-center justify-center space-y-5">
|
||||
<div className="w-14 h-14 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-500 flex items-center justify-center">
|
||||
<Check size={28} className="stroke-[2.5]" />
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2 px-2">
|
||||
<span className="text-[9px] bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-bold px-2 py-0.5 rounded uppercase tracking-wider">
|
||||
Traitement Réussi
|
||||
</span>
|
||||
<h3 className="text-sm font-bold text-ink dark:text-dark-ink font-serif leading-tight">
|
||||
{aiGeneratedTitle}
|
||||
</h3>
|
||||
<p className="text-[10px] text-concrete">
|
||||
Note envoyée dans le carnet <span className="font-bold">"{carnets.find(c => c.id === selectedCarnetId)?.name}"</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full border-t border-neutral-100 dark:border-neutral-800/80 my-1 pt-4 flex flex-wrap gap-1.5 justify-center">
|
||||
{activeArticle.suggestedTags.map((t, i) => (
|
||||
<span key={i} className="text-[9px] bg-accent/5 font-bold uppercase tracking-wider text-accent border border-accent/20 px-2.5 py-1 rounded-full flex items-center gap-1">
|
||||
<Sparkles size={10} />
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'notebooks' }));
|
||||
window.dispatchEvent(new CustomEvent('open-note', { detail: lastCreatedNoteId }));
|
||||
onClose();
|
||||
}}
|
||||
className="w-full py-3.5 bg-ink text-paper rounded-xl text-xs font-bold uppercase tracking-widest flex items-center justify-center gap-2 hover:opacity-95 transition-opacity"
|
||||
>
|
||||
Voir dans Momento
|
||||
<ArrowUpRight size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleResetClipper}
|
||||
className="w-full py-2 text-xs text-concrete hover:text-ink hover:underline text-center"
|
||||
>
|
||||
Clipper une autre page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STATE: ERROR */}
|
||||
{clipperState === 'error' && (
|
||||
<div className="flex-1 flex flex-col justify-between py-2">
|
||||
<div className="flex-1 flex flex-col items-center justify-center space-y-4">
|
||||
<div className="w-14 h-14 rounded-full bg-red-100 dark:bg-rose-950/20 text-red-500 flex items-center justify-center">
|
||||
<AlertTriangle size={28} />
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2 px-2">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-red-500">
|
||||
Échec de la capture
|
||||
</p>
|
||||
<p className="text-xs text-neutral-600 dark:text-zinc-400 leading-normal max-w-[260px] mx-auto">
|
||||
{customError || "Une erreur s'est produite lors de la transmission à votre instance."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={handleResetClipper}
|
||||
className="w-full py-3.5 bg-red-500 hover:bg-red-600 text-white rounded-xl text-xs font-bold uppercase tracking-widest transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Simulated context details */}
|
||||
<footer className="px-5 py-3 border-t border-neutral-100 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/40 text-[9px] text-concrete text-center">
|
||||
Momento Companion v2.1.2 • Sécurisé HTTPS TLS 1.3
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
874
architectural-grid1/src/components/GraphKnowledgeMap.tsx
Normal file
874
architectural-grid1/src/components/GraphKnowledgeMap.tsx
Normal file
@@ -0,0 +1,874 @@
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Note, Carnet, Tag } from '../types';
|
||||
import {
|
||||
Network,
|
||||
Search,
|
||||
Sliders,
|
||||
HelpCircle,
|
||||
X,
|
||||
Filter,
|
||||
Compass,
|
||||
BookOpen,
|
||||
Eye,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Minus,
|
||||
Maximize2,
|
||||
ChevronLeft,
|
||||
Calendar,
|
||||
Layers,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
|
||||
interface GraphKnowledgeMapProps {
|
||||
notes: Note[];
|
||||
carnets: Carnet[];
|
||||
onOpenNote: (noteId: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// 7 Gorgeous colors corresponding to the carnets palette
|
||||
const CARNET_COLOR_PALETTE: { [key: string]: string } = {
|
||||
'1': '#D97706', // Daily Notes - Warm Amber
|
||||
'2': '#059669', // Project: Neo - Soft Emerald
|
||||
'3': '#4F46E5', // Shared Docs - Rich Indigo
|
||||
'4': '#0891B2', // Architecture Research - Clean Cyan
|
||||
'5': '#EA580C', // History of Architecture - Deep Orange
|
||||
'6': '#DB2777', // Modernism - Vibrant Rose
|
||||
'7': '#65A30D', // Sustainable Design - Cool Lime
|
||||
};
|
||||
|
||||
const DEFAULT_CARNET_COLOR = '#71717A'; // Zinc
|
||||
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string;
|
||||
title: string;
|
||||
carnetId: string;
|
||||
carnetName: string;
|
||||
color: string;
|
||||
date: string;
|
||||
snippet: string;
|
||||
tags: Tag[];
|
||||
degree: number;
|
||||
}
|
||||
|
||||
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
||||
source: string | D3Node;
|
||||
target: string | D3Node;
|
||||
type: 'wikilink' | 'semantic';
|
||||
strength: number;
|
||||
}
|
||||
|
||||
export const GraphKnowledgeMap: React.FC<GraphKnowledgeMapProps> = ({
|
||||
notes,
|
||||
carnets,
|
||||
onOpenNote,
|
||||
onClose
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
// Settings & Toggles
|
||||
const [showSemanticLinks, setShowSemanticLinks] = useState(true);
|
||||
const [minSemanticStrength, setMinSemanticStrength] = useState(0.40); // threshold
|
||||
const [selectedCarnetIds, setSelectedCarnetIds] = useState<string[]>([]);
|
||||
|
||||
// Interaction States
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [hoveredNode, setHoveredNode] = useState<D3Node | null>(null);
|
||||
const [activeLocalNode, setActiveLocalNode] = useState<D3Node | null>(null);
|
||||
const [nodeConnections, setNodeConnections] = useState<Set<string>>(new Set());
|
||||
|
||||
// D3 Zoom controller ref to trigger programmatically
|
||||
const d3ZoomRef = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null);
|
||||
|
||||
// Initialize carnet filters with all carnets on mount
|
||||
useEffect(() => {
|
||||
setSelectedCarnetIds(carnets.map(c => c.id));
|
||||
}, [carnets]);
|
||||
|
||||
// Static list of explicit links (Wikilinks)
|
||||
const explicitWikiLinks = useMemo(() => {
|
||||
return [
|
||||
{ source: 'n1', target: 'n1-b' },
|
||||
{ source: 'n3', target: 'n3-b' },
|
||||
{ source: 'bridge-1', target: 'n1' },
|
||||
{ source: 'bridge-1', target: 'n2' },
|
||||
];
|
||||
}, []);
|
||||
|
||||
// Filter and process notes and carnets
|
||||
const filteredNotes = useMemo(() => {
|
||||
return notes.filter(n => {
|
||||
// Exclude trashed/deleted notes
|
||||
if (n.isDeleted) return false;
|
||||
// Filter by selected carnets
|
||||
return selectedCarnetIds.includes(n.carnetId);
|
||||
});
|
||||
}, [notes, selectedCarnetIds]);
|
||||
|
||||
// Compute all links based on state (Wikilinks + Semantic if enabled)
|
||||
const graphData = useMemo(() => {
|
||||
const noteMap = new Map<string, Note>();
|
||||
filteredNotes.forEach(n => noteMap.set(n.id, n));
|
||||
|
||||
const nodes: D3Node[] = filteredNotes.map(n => {
|
||||
const carnet = carnets.find(c => c.id === n.carnetId);
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
carnetId: n.carnetId,
|
||||
carnetName: carnet?.name || 'Carnet Inconnu',
|
||||
color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR,
|
||||
date: n.date,
|
||||
snippet: n.content.split('.').slice(0, 3).join('.') + '.',
|
||||
tags: n.tags || [],
|
||||
degree: 0, // calculated below
|
||||
x: undefined,
|
||||
y: undefined
|
||||
};
|
||||
});
|
||||
|
||||
const links: D3Link[] = [];
|
||||
|
||||
// 1. Add Explicit Wikilinks if both target and source are inside filtered list
|
||||
explicitWikiLinks.forEach(link => {
|
||||
if (noteMap.has(link.source) && noteMap.has(link.target)) {
|
||||
links.push({
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
type: 'wikilink',
|
||||
strength: 1.0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Add Semantic Connections (Memory Echo) based on embedding similarities
|
||||
if (showSemanticLinks) {
|
||||
for (let i = 0; i < filteredNotes.length; i++) {
|
||||
for (let j = i + 1; j < filteredNotes.length; j++) {
|
||||
const ni = filteredNotes[i];
|
||||
const nj = filteredNotes[j];
|
||||
|
||||
if (ni.embedding && nj.embedding) {
|
||||
// Cosine vector similarity approximation / Euclidean inverse mapping
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(ni.embedding[0] - nj.embedding[0], 2) +
|
||||
Math.pow(ni.embedding[1] - nj.embedding[1], 2)
|
||||
);
|
||||
// Translate distance into similarity standard (0.0 - 1.0)
|
||||
const similarity = Math.max(0, 1 - dist * 0.7);
|
||||
|
||||
if (similarity >= minSemanticStrength) {
|
||||
// Avoid duplicate links with explicit ones to keep display clean
|
||||
const hasExplicit = explicitWikiLinks.some(
|
||||
ex => (ex.source === ni.id && ex.target === nj.id) || (ex.source === nj.id && ex.target === ni.id)
|
||||
);
|
||||
|
||||
if (!hasExplicit) {
|
||||
links.push({
|
||||
source: ni.id,
|
||||
target: nj.id,
|
||||
type: 'semantic',
|
||||
strength: similarity
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate node connectivity degrees
|
||||
nodes.forEach(node => {
|
||||
const connectionsCount = links.filter(l =>
|
||||
l.source === node.id || l.target === node.id ||
|
||||
(typeof l.source === 'object' && (l.source as any).id === node.id) ||
|
||||
(typeof l.target === 'object' && (l.target as any).id === node.id)
|
||||
).length;
|
||||
node.degree = connectionsCount;
|
||||
});
|
||||
|
||||
return { nodes, links };
|
||||
}, [filteredNotes, carnets, showSemanticLinks, minSemanticStrength, selectedCarnetIds, explicitWikiLinks]);
|
||||
|
||||
// Handle Note connection highlights during hover
|
||||
useEffect(() => {
|
||||
if (!hoveredNode) {
|
||||
setNodeConnections(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const connected = new Set<string>();
|
||||
connected.add(hoveredNode.id);
|
||||
|
||||
graphData.links.forEach((l: any) => {
|
||||
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
|
||||
if (srcId === hoveredNode.id) {
|
||||
connected.add(tgtId);
|
||||
} else if (tgtId === hoveredNode.id) {
|
||||
connected.add(srcId);
|
||||
}
|
||||
});
|
||||
|
||||
setNodeConnections(connected);
|
||||
}, [hoveredNode, graphData.links]);
|
||||
|
||||
// Main D3 force layout rendering loop
|
||||
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();
|
||||
|
||||
// Base containment group
|
||||
const mainGroup = svg.append("g");
|
||||
|
||||
// Configure zooming behaviors
|
||||
const zoomBehavior = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.15, 5])
|
||||
.on("zoom", (event) => {
|
||||
mainGroup.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
d3ZoomRef.current = zoomBehavior;
|
||||
svg.call(zoomBehavior);
|
||||
|
||||
// D3 nodes and links references mapped to copyable arrays
|
||||
const simulationNodes = JSON.parse(JSON.stringify(graphData.nodes)) as D3Node[];
|
||||
const simulationLinks = graphData.links.map(l => ({
|
||||
source: l.source,
|
||||
target: l.target,
|
||||
type: l.type,
|
||||
strength: l.strength
|
||||
})) as D3Link[];
|
||||
|
||||
// Build the force simulation
|
||||
const simulation = d3.forceSimulation<D3Node>(simulationNodes)
|
||||
.force("link", d3.forceLink<D3Node, any>(simulationLinks)
|
||||
.id(d => d.id)
|
||||
.distance(d => d.type === 'wikilink' ? 100 : 140)
|
||||
)
|
||||
.force("charge", d3.forceManyBody().strength(-240))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collision", d3.forceCollide<D3Node>().radius(d => {
|
||||
// Size proportional to connections: min 8px, max 20px
|
||||
const rad = 8 + Math.min(d.degree * 2.5, 12);
|
||||
return rad + 24;
|
||||
}));
|
||||
|
||||
// Draw Links
|
||||
const linkGroup = mainGroup.append("g")
|
||||
.attr("class", "links-layer");
|
||||
|
||||
const link = linkGroup.selectAll("line")
|
||||
.data(simulationLinks)
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("stroke", d => d.type === 'semantic' ? '#4f46e5' : '#18181b')
|
||||
.attr("stroke-opacity", d => d.type === 'semantic' ? 0.35 : 0.18)
|
||||
.attr("stroke-width", d => d.type === 'semantic' ? 1.2 : 1.5)
|
||||
.attr("stroke-dasharray", d => d.type === 'semantic' ? '4,4' : 'none');
|
||||
|
||||
// Draw Nodes
|
||||
const nodeGroup = mainGroup.append("g")
|
||||
.attr("class", "nodes-layer");
|
||||
|
||||
const node = nodeGroup.selectAll(".node")
|
||||
.data(simulationNodes)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "node cursor-pointer")
|
||||
.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
handleSelectNode(d);
|
||||
})
|
||||
.on("mouseenter", (event, d) => {
|
||||
setHoveredNode(d);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
setHoveredNode(null);
|
||||
})
|
||||
.call(d3.drag<SVGGElement, D3Node>()
|
||||
.on("start", dragStarted)
|
||||
.on("drag", dragged)
|
||||
.on("end", dragEnded) as any);
|
||||
|
||||
// Create central circles
|
||||
node.append("circle")
|
||||
.attr("r", d => 6 + Math.min(d.degree * 1.5, 9))
|
||||
.attr("fill", d => d.color)
|
||||
.attr("stroke", "rgba(255,255,255,0.95)")
|
||||
.attr("stroke-width", 2)
|
||||
.attr("class", "transition-all duration-300 dark:stroke-zinc-950")
|
||||
.style("filter", "drop-shadow(0 2px 4px rgba(0,0,0,0.1))");
|
||||
|
||||
// Text labels overlay
|
||||
node.append("text")
|
||||
.attr("dy", d => 14 + Math.min(d.degree * 1.5, 9) + 4)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("class", "text-[10px] sm:text-[11px] font-sans font-semibold tracking-tight fill-zinc-850 dark:fill-zinc-300 select-none pointer-events-none")
|
||||
.text(d => d.title.length > 22 ? d.title.substring(0, 20) + "..." : d.title)
|
||||
.style("opacity", d => (d.degree > 2 || d.title.toLowerCase().includes(searchQuery.toLowerCase()) && searchQuery) ? 1 : 0.65);
|
||||
|
||||
// Search query search highlight ring
|
||||
if (searchQuery) {
|
||||
node.filter(d => d.title.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
.append("circle")
|
||||
.attr("r", d => 14 + Math.min(d.degree * 1.5, 9))
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#06b6d4")
|
||||
.attr("stroke-width", 2)
|
||||
.attr("stroke-dasharray", "3,1")
|
||||
.attr("class", "animate-[spin_20s_linear_infinite]");
|
||||
}
|
||||
|
||||
// Node active local neighbor rings
|
||||
if (activeLocalNode) {
|
||||
const activeConns = getLocalNodeNeighbors(activeLocalNode.id);
|
||||
|
||||
node.style("opacity", d => {
|
||||
return activeConns.has(d.id) ? 1.0 : 0.15;
|
||||
});
|
||||
|
||||
link.style("stroke-opacity", (l: any) => {
|
||||
const srcId = l.source.id;
|
||||
const tgtId = l.target.id;
|
||||
return (activeConns.has(srcId) && activeConns.has(tgtId)) ? 0.75 : 0.05;
|
||||
});
|
||||
|
||||
// Highlight the focused local hub node with a neat accent circle
|
||||
node.filter(d => d.id === activeLocalNode.id)
|
||||
.append("circle")
|
||||
.attr("r", d => 16 + Math.min(d.degree * 1.5, 9))
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "rgba(79, 70, 229, 0.4)")
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("stroke-opacity", 0.9);
|
||||
}
|
||||
// Node hover lighting state
|
||||
else if (hoveredNode) {
|
||||
const hoveredConns = new Set<string>();
|
||||
hoveredConns.add(hoveredNode.id);
|
||||
|
||||
graphData.links.forEach((l: any) => {
|
||||
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
|
||||
if (srcId === hoveredNode.id) {
|
||||
hoveredConns.add(tgtId);
|
||||
} else if (tgtId === hoveredNode.id) {
|
||||
hoveredConns.add(srcId);
|
||||
}
|
||||
});
|
||||
|
||||
// Subdue unconnected elements to 20% opacity
|
||||
node.style("opacity", d => hoveredConns.has(d.id) ? 1.0 : 0.20);
|
||||
link.style("stroke-opacity", (l: any) => {
|
||||
const srcId = l.source.id;
|
||||
const tgtId = l.target.id;
|
||||
return (srcId === hoveredNode.id || tgtId === hoveredNode.id) ? 0.8 : 0.05;
|
||||
});
|
||||
|
||||
// Hover scale update for primary
|
||||
node.filter(d => d.id === hoveredNode.id)
|
||||
.select("circle")
|
||||
.attr("transform", "scale(1.3)");
|
||||
}
|
||||
// Normal / Base state
|
||||
else {
|
||||
node.style("opacity", 1.0);
|
||||
link.style("stroke-opacity", d => d.type === 'semantic' ? 0.35 : 0.18);
|
||||
}
|
||||
|
||||
// Run ticks
|
||||
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})`);
|
||||
});
|
||||
|
||||
// Zoom on local node view trigger
|
||||
if (activeLocalNode && width && height) {
|
||||
const targetNodeCopy = simulationNodes.find(n => n.id === activeLocalNode.id);
|
||||
if (targetNodeCopy) {
|
||||
// Step ticker synchronously to finalize force state layout
|
||||
for (let i = 0; i < 40; ++i) simulation.tick();
|
||||
|
||||
const x = targetNodeCopy.x || width / 2;
|
||||
const y = targetNodeCopy.y || height / 2;
|
||||
|
||||
svg.transition()
|
||||
.duration(850)
|
||||
.ease(d3.easeCubicOut)
|
||||
.call(
|
||||
zoomBehavior.transform,
|
||||
d3.zoomIdentity.translate(width / 2, height / 2).scale(1.65).translate(-x, -y)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Re-center whole graph
|
||||
svg.transition()
|
||||
.duration(800)
|
||||
.ease(d3.easeCubicOut)
|
||||
.call(zoomBehavior.transform, d3.zoomIdentity);
|
||||
}
|
||||
|
||||
function dragStarted(event: any, d: any) {
|
||||
if (!event.active) simulation.alphaTarget(0.25).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event: any, d: any) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragEnded(event: any, d: any) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
simulation.stop();
|
||||
};
|
||||
}, [graphData, showSemanticLinks, minSemanticStrength, searchQuery, activeLocalNode, hoveredNode]);
|
||||
|
||||
// Compute local neighbors
|
||||
const getLocalNodeNeighbors = (nodeId: string): Set<string> => {
|
||||
const list = new Set<string>();
|
||||
list.add(nodeId);
|
||||
graphData.links.forEach(l => {
|
||||
if (l.source === nodeId) {
|
||||
list.add(typeof l.target === 'object' ? (l.target as any).id : l.target);
|
||||
} else if (l.target === nodeId) {
|
||||
list.add(typeof l.source === 'object' ? (l.source as any).id : l.source);
|
||||
}
|
||||
});
|
||||
return list;
|
||||
};
|
||||
|
||||
const handleSelectNode = (node: D3Node) => {
|
||||
setActiveLocalNode(node);
|
||||
};
|
||||
|
||||
const handleResetLocalView = () => {
|
||||
setActiveLocalNode(null);
|
||||
};
|
||||
|
||||
const handleZoom = (direction: 'in' | 'out' | 'fit') => {
|
||||
if (!svgRef.current || !d3ZoomRef.current) return;
|
||||
const svg = d3.select(svgRef.current);
|
||||
|
||||
if (direction === 'fit') {
|
||||
svg.transition().duration(500).call(d3ZoomRef.current.transform, d3.zoomIdentity);
|
||||
} else {
|
||||
const factor = direction === 'in' ? 1.3 : 1 / 1.3;
|
||||
svg.transition().duration(400).call(d3ZoomRef.current.scaleBy, factor);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCarnetSelector = (carnetId: string) => {
|
||||
setSelectedCarnetIds(prev =>
|
||||
prev.includes(carnetId)
|
||||
? prev.filter(id => id !== carnetId)
|
||||
: [...prev, carnetId]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAllCarnets = () => {
|
||||
setSelectedCarnetIds(carnets.map(c => c.id));
|
||||
};
|
||||
|
||||
const clearAllCarnets = () => {
|
||||
setSelectedCarnetIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full flex flex-row overflow-hidden relative">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 h-full relative overflow-hidden bg-paper dark:bg-[#0E0E0E]"
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(rgba(120, 119, 198, 0.04) 1px, transparent 1.5px)',
|
||||
backgroundSize: '24px 24px'
|
||||
}}
|
||||
>
|
||||
{/* Dynamic Header Overlay */}
|
||||
<div className="absolute top-5 left-5 z-20 flex items-center gap-3">
|
||||
{activeLocalNode ? (
|
||||
<button
|
||||
onClick={handleResetLocalView}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-border/80 hover:border-accent text-accent rounded-xl text-xs font-bold uppercase tracking-wider transition-all shadow-md"
|
||||
>
|
||||
<ChevronLeft size={14} className="stroke-[2.5]" />
|
||||
Graphe Global
|
||||
</button>
|
||||
) : onClose ? (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-border/80 hover:border-black text-ink rounded-xl text-xs font-bold uppercase tracking-wider transition-all shadow-md"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
Retour Notes
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-border/60 rounded-xl">
|
||||
<Compass size={14} className="text-accent" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-ink dark:text-dark-ink">Carte Sémantique</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden md:flex items-center bg-zinc-950/5 dark:bg-white/5 border border-border px-3 py-1.5 rounded-xl text-[11px] text-concrete font-medium gap-1.5 shadow-sm">
|
||||
<span className="font-bold text-ink dark:text-dark-ink">{graphData.nodes.length} Nœuds</span>
|
||||
<span className="opacity-30">|</span>
|
||||
<span>{graphData.links.length} Relations</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Hub Search Bar */}
|
||||
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-20 w-[90%] max-w-[360px]">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Chercher une note dans le graphe sémantique..."
|
||||
className="w-full text-xs pl-9 pr-8 py-2.5 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/95 dark:bg-zinc-950/95 placeholder-concrete/60 shadow-lg outline-none focus:border-accent focus:ring-1 focus:ring-accent/10 transition-all text-ink dark:text-dark-ink font-medium"
|
||||
/>
|
||||
<Search size={14} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-concrete" />
|
||||
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 rounded-full"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls (bottom right) */}
|
||||
<div className="absolute bottom-6 right-6 z-20 flex flex-col gap-1.5 bg-white/90 dark:bg-zinc-900/90 backdrop-blur p-1.5 rounded-xl border border-border/60 shadow-xl">
|
||||
<button
|
||||
onClick={() => handleZoom('in')}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg text-concrete hover:text-ink transition-colors"
|
||||
title="Zoomer (+)"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleZoom('out')}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg text-concrete hover:text-ink transition-colors"
|
||||
title="Dézoomer (-)"
|
||||
>
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
|
||||
<div className="h-[1px] bg-border mx-1 my-0.5" />
|
||||
|
||||
<button
|
||||
onClick={() => handleZoom('fit')}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg text-concrete hover:text-accent transition-colors"
|
||||
title="Ajuster la vue"
|
||||
>
|
||||
<Maximize2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Floating Controls Panel (top right) */}
|
||||
<div className="absolute top-5 right-5 z-20 w-[300px] hidden lg:block">
|
||||
<div className="bg-white/95 dark:bg-zinc-950/95 backdrop-blur border border-neutral-200 dark:border-neutral-800 rounded-2xl shadow-xl overflow-hidden">
|
||||
<div className="px-4.5 py-3 border-b border-neutral-100 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/10 flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete flex items-center gap-1.5">
|
||||
<Sliders size={11} className="text-secondary" />
|
||||
Paramètres du Graphe
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSemanticLinks(true);
|
||||
setMinSemanticStrength(0.40);
|
||||
selectAllCarnets();
|
||||
}}
|
||||
className="text-[9px] font-bold uppercase text-accent hover:text-accent/80 transition-colors"
|
||||
title="Rétablir par défaut"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Semantic Link Toggle Details */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="semantic-links-toggle" className="text-[11px] font-bold text-ink dark:text-dark-ink flex items-center gap-1.5">
|
||||
<Sparkles size={12} className="text-indigo-500" />
|
||||
Liens sémantiques
|
||||
</label>
|
||||
<input
|
||||
id="semantic-links-toggle"
|
||||
type="checkbox"
|
||||
checked={showSemanticLinks}
|
||||
onChange={(e) => setShowSemanticLinks(e.target.checked)}
|
||||
className="w-4 h-4 text-accent border-gray-300 rounded focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-concrete leading-normal pl-5">
|
||||
Visualiser la couche d'affinité IA générée par embeddings sémantiques (Memory Echo).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Slider for semantic filtering threshold - Displayed only if activated */}
|
||||
{showSemanticLinks && (
|
||||
<div className="pt-1.5 pb-0.5 space-y-2.5 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex justify-between items-center text-[10px] font-bold text-concrete">
|
||||
<span>Force minimum sémantique</span>
|
||||
<span className="font-mono text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-950/40 px-1.5 py-0.5 rounded">
|
||||
{(minSemanticStrength * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] font-mono text-concrete">0.2</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.20"
|
||||
max="0.85"
|
||||
step="0.05"
|
||||
value={minSemanticStrength}
|
||||
onChange={(e) => setMinSemanticStrength(parseFloat(e.target.value))}
|
||||
className="w-full h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-indigo-600"
|
||||
/>
|
||||
<span className="text-[9px] font-mono text-concrete font-bold">0.85</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter by Carnets with Checkboxes */}
|
||||
<div className="pt-3 border-t border-neutral-100 dark:border-neutral-800 space-y-2.5">
|
||||
<div className="flex items-center justify-between text-[11px] font-bold text-ink dark:text-dark-ink">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Layers size={11} className="text-emerald-500" />
|
||||
Filtrer par Carnet ({selectedCarnetIds.length})
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[9px] text-concrete">
|
||||
<button onClick={selectAllCarnets} className="hover:underline">Tous</button>
|
||||
<span>•</span>
|
||||
<button onClick={clearAllCarnets} className="hover:underline">Aucun</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-[140px] overflow-y-auto pr-1">
|
||||
{carnets.map(c => {
|
||||
const isChecked = selectedCarnetIds.includes(c.id);
|
||||
const carnetColor = CARNET_COLOR_PALETTE[c.id] || DEFAULT_CARNET_COLOR;
|
||||
return (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex items-center justify-between text-[10.5px] text-concrete hover:text-ink cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-900/40 py-1 px-1.5 rounded transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: carnetColor }} />
|
||||
<span className="truncate max-w-[150px]">{c.name}</span>
|
||||
</span>
|
||||
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleCarnetSelector(c.id)}
|
||||
className="w-3.5 h-3.5 text-accent border-gray-300 rounded focus:ring-accent"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Tooltip Hover UI Card (In case of node hovering) */}
|
||||
<AnimatePresence>
|
||||
{hoveredNode && !activeLocalNode && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 15 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="absolute bottom-8 left-8 z-30 w-[280px] bg-zinc-950 text-white rounded-xl shadow-2xl p-4.5 border border-zinc-800 space-y-3.5"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded text-white font-mono" style={{ backgroundColor: hoveredNode.color }}>
|
||||
{hoveredNode.carnetName}
|
||||
</span>
|
||||
|
||||
<span className="text-[9.5px] font-mono text-zinc-400">
|
||||
Modifié le : {hoveredNode.date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="text-xs font-bold leading-tight line-clamp-2 text-zinc-100 font-serif">
|
||||
{hoveredNode.title}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* Micro Metrics stats */}
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-zinc-900 pt-3">
|
||||
<div className="bg-zinc-900/50 p-2 rounded-lg text-center">
|
||||
<span className="text-[9px] block text-zinc-500 uppercase tracking-wider">Connexions</span>
|
||||
<p className="text-xs font-black text-indigo-400">{hoveredNode.degree}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900/50 p-2 rounded-lg text-center">
|
||||
<span className="text-[9px] block text-zinc-500 uppercase tracking-wider">Tags détectés</span>
|
||||
<p className="text-xs font-black text-cyan-400">{hoveredNode.tags.length || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[9.5px] text-zinc-400 font-medium italic flex items-center justify-center gap-1">
|
||||
<span>Cliquez pour isoler / modifier</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* SVG Core Render canvas */}
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
</div>
|
||||
|
||||
{/* State D: Note focus right panel slider (280px width) */}
|
||||
<AnimatePresence>
|
||||
{activeLocalNode && (
|
||||
<motion.div
|
||||
initial={{ x: '100%', opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: '100%', opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 180 }}
|
||||
className="w-[320px] bg-white dark:bg-neutral-950 border-l border-neutral-200 dark:border-neutral-800 shadow-2xl z-20 flex flex-col justify-between"
|
||||
>
|
||||
{/* Panel header and close button */}
|
||||
<div className="p-5 border-b border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between mb-4.5">
|
||||
<div className="flex items-center gap-2 text-[10px] uppercase font-bold tracking-widest text-[#4f46e5]">
|
||||
<Sparkles size={12} className="text-indigo-500 animate-[pulse_3s_infinite]" />
|
||||
Aperçu de Note
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleResetLocalView}
|
||||
className="p-1 px-2.5 rounded hover:bg-neutral-50 dark:hover:bg-neutral-900 text-[10.5px] font-bold tracking-tight text-concrete hover:text-ink select-none border border-neutral-200 dark:border-neutral-800"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Note details */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[9.5px] font-bold text-zinc-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: activeLocalNode.color }} />
|
||||
<span className="uppercase tracking-wider truncate max-w-[200px]">{activeLocalNode.carnetName}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-black text-ink dark:text-dark-ink font-serif leading-tight">
|
||||
{activeLocalNode.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-[10px] text-concrete font-mono flex items-center gap-1">
|
||||
<Calendar size={10} />
|
||||
Dernier update : {activeLocalNode.date}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snippet body content */}
|
||||
<div className="flex-1 p-5 overflow-y-auto space-y-4">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[9px] uppercase font-bold tracking-widest text-concrete block">Résumé / Extrait</span>
|
||||
<p className="text-xs text-ink/80 dark:text-dark-ink/80 italic leading-relaxed bg-[#FAF9F5]/40 dark:bg-neutral-900 p-3.5 rounded-xl border border-[#FAF9F5] dark:border-neutral-900 select-all">
|
||||
"{activeLocalNode.snippet}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Relationship listing */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-[9px] uppercase font-bold tracking-widest text-concrete block">
|
||||
Éléments connectés ({getLocalNodeNeighbors(activeLocalNode.id).size - 1})
|
||||
</span>
|
||||
|
||||
<div className="space-y-1.5 max-h-[160px] overflow-y-auto pr-1">
|
||||
{notes
|
||||
.filter(n => n.id !== activeLocalNode.id && getLocalNodeNeighbors(activeLocalNode.id).has(n.id))
|
||||
.map(neighbor => {
|
||||
return (
|
||||
<div
|
||||
key={neighbor.id}
|
||||
onClick={() => {
|
||||
const foundNode = graphData.nodes.find(v => v.id === neighbor.id);
|
||||
if (foundNode) handleSelectNode(foundNode);
|
||||
}}
|
||||
className="flex items-center justify-between text-[10px] p-2 bg-neutral-50 dark:bg-neutral-900/60 rounded-xl hover:bg-neutral-100 cursor-pointer border border-transparent hover:border-border transition-colors group"
|
||||
>
|
||||
<span className="font-semibold text-ink dark:text-dark-ink truncate max-w-[170px] flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: CARNET_COLOR_PALETTE[neighbor.carnetId] || DEFAULT_CARNET_COLOR }} />
|
||||
{neighbor.title}
|
||||
</span>
|
||||
<span className="text-[8px] font-bold uppercase tracking-wider text-concrete group-hover:text-accent group-hover:underline">
|
||||
Séléctionner
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags panel detail */}
|
||||
{activeLocalNode.tags && activeLocalNode.tags.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[9px] uppercase font-bold tracking-widest text-concrete block">Index de tags</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{activeLocalNode.tags.map((t, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-[9px] font-semibold uppercase tracking-wider border border-border bg-neutral-50/40 text-concrete px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CTA action bottom block */}
|
||||
<div className="p-5 border-t border-neutral-100 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/10 flex flex-col gap-2.5">
|
||||
<button
|
||||
onClick={() => onOpenNote(activeLocalNode.id)}
|
||||
className="w-full py-3.5 bg-ink text-paper dark:bg-neutral-50 dark:text-zinc-950 rounded-xl text-xs font-bold uppercase tracking-widest hover:opacity-95 transition-all text-center flex items-center justify-center gap-1.5 shadow-xl shadow-black/10 scale-100 active:scale-95"
|
||||
>
|
||||
<FileText size={13} />
|
||||
Ouvrir la note
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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-accent/10 text-accent 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-accent/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-accent/5 focus:border-accent/40 transition-all cursor-pointer text-ink flex items-center gap-3"
|
||||
>
|
||||
<Folder size={16} className="text-accent/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-accent 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-accent hover:underline"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
482
architectural-grid1/src/components/InsightsView.tsx
Normal file
482
architectural-grid1/src/components/InsightsView.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
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,
|
||||
Menu,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
TrendingUp,
|
||||
Sliders,
|
||||
CheckCircle2,
|
||||
Lock
|
||||
} from 'lucide-react';
|
||||
import { Note, NoteCluster, BridgeNote, ConnectionSuggestion } from '../types';
|
||||
import { runClustering, detectBridges, calculateCentroid, getMostCentralNoteTitles } 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;
|
||||
onOpenSidebar?: () => void;
|
||||
}
|
||||
|
||||
export const InsightsView: React.FC<InsightsViewProps> = ({
|
||||
notes,
|
||||
onUpdateNotes,
|
||||
onNoteSelect,
|
||||
onOpenSidebar
|
||||
}) => {
|
||||
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);
|
||||
|
||||
// Mobile responsive view selector
|
||||
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard');
|
||||
|
||||
// Interactive automatic recalculation parameters simulator / status
|
||||
const [lastSyncTime, setLastSyncTime] = useState<string>(() => {
|
||||
return new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
});
|
||||
|
||||
// Track changes to notes since last calculation to show a conditions indicator
|
||||
const [notesModifiedCount, setNotesModifiedCount] = useState(0);
|
||||
|
||||
// Monitor edits to emulate the state "Recalcul quotidien planifié" or condition (>10 notes modified)
|
||||
useEffect(() => {
|
||||
// Whenever notes length or contents change, we simulate a tally
|
||||
setNotesModifiedCount(prev => Math.min(prev + 1, 12));
|
||||
}, [notes.length]);
|
||||
|
||||
const performAnalysis = async () => {
|
||||
setIsCalculating(true);
|
||||
try {
|
||||
// 1. Run clustering (DBSCAN acting on density with outlier filtering, label -1 is outlier)
|
||||
const { clusters: newClusters } = runClustering(notes);
|
||||
|
||||
// 2. Name clusters (find the 5 notes closest to each cluster's centroid vector)
|
||||
const namedClusters = await Promise.all(newClusters.map(async (c) => {
|
||||
const centroid = calculateCentroid(c.noteIds, notes);
|
||||
// Find the 5 most central notes (closest to the cluster centroid by cosine similarity)
|
||||
const clusterNoteSummaries = getMostCentralNoteTitles(c.noteIds, centroid, notes, 5);
|
||||
|
||||
const name = await nameCluster(clusterNoteSummaries);
|
||||
|
||||
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 (notes exhibiting similarity > 0.5 to >= 2 clusters)
|
||||
const bridges = detectBridges(updatedNotes, namedClusters);
|
||||
|
||||
// 5. Build suggestions for unconnected cluster pairs
|
||||
// A pair is unconnected if there are no existing bridge notes linking them
|
||||
const newSuggestions: ConnectionSuggestion[] = [];
|
||||
if (namedClusters.length >= 2) {
|
||||
const unconnectedPairs: { cA: NoteCluster; cB: NoteCluster }[] = [];
|
||||
|
||||
for (let i = 0; i < namedClusters.length; i++) {
|
||||
for (let j = i + 1; j < namedClusters.length; j++) {
|
||||
const cA = namedClusters[i];
|
||||
const cB = namedClusters[j];
|
||||
|
||||
// Check if any bridge note connects these two clusters
|
||||
const hasBridge = bridges.some(b =>
|
||||
b.connectedClusterIds.includes(cA.id) && b.connectedClusterIds.includes(cB.id)
|
||||
);
|
||||
|
||||
if (!hasBridge) {
|
||||
unconnectedPairs.push({ cA, cB });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate bridge suggestions for the top 3 unconnected pairs
|
||||
for (let k = 0; k < Math.min(unconnectedPairs.length, 3); k++) {
|
||||
const { cA, cB } = unconnectedPairs[k];
|
||||
const cA_notes = updatedNotes.filter(n => cA.noteIds.includes(n.id)).map(n => n.title).slice(0, 3).join(', ');
|
||||
const cB_notes = updatedNotes.filter(n => cB.noteIds.includes(n.id)).map(n => n.title).slice(0, 3).join(', ');
|
||||
|
||||
const bridgeIdeas = await suggestBridgeIdeas(cA.name, cB.name, cA_notes, cB_notes);
|
||||
bridgeIdeas.forEach((idea, idx) => {
|
||||
newSuggestions.push({
|
||||
id: `suggestion-${cA.id}-${cB.id}-${idx}`,
|
||||
...idea,
|
||||
clusterAId: cA.id,
|
||||
clusterBId: cB.id
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setClusters(namedClusters);
|
||||
setBridgeNotes(bridges);
|
||||
setSuggestions(newSuggestions);
|
||||
setLastSyncTime(new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }));
|
||||
setNotesModifiedCount(0); // Reset modified counter upon successful clustering recalculation
|
||||
} 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 || 'Note de passage' };
|
||||
});
|
||||
}, [bridgeNotes, notes]);
|
||||
|
||||
// Compute isolated clusters (ones with no bridge notes spanning to them)
|
||||
const isolatedClusters = useMemo(() => {
|
||||
const networkedClusterIds = new Set(bridgeNotes.flatMap(b => b.connectedClusterIds));
|
||||
return clusters.filter(c => !networkedClusterIds.has(c.id));
|
||||
}, [clusters, bridgeNotes]);
|
||||
|
||||
// Find currently selected cluster info for the zoom drilldown list
|
||||
const selectedCluster = useMemo(() => {
|
||||
return clusters.find(c => c.id === selectedClusterId);
|
||||
}, [clusters, selectedClusterId]);
|
||||
|
||||
const selectedClusterNotes = useMemo(() => {
|
||||
if (!selectedCluster) return [];
|
||||
return notes.filter(n => selectedCluster.noteIds.includes(n.id));
|
||||
}, [notes, selectedCluster]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-dark-paper overflow-hidden font-sans">
|
||||
{/* Header with Mobile Drawer Trigger & Responsiveness Tab controls */}
|
||||
<div className="p-6 sm:p-8 border-b border-border/20 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sticky top-0 bg-[#F9F8F6]/80 dark:bg-dark-paper/80 backdrop-blur-md z-30">
|
||||
<div className="flex items-center gap-4">
|
||||
{onOpenSidebar && (
|
||||
<button
|
||||
onClick={onOpenSidebar}
|
||||
className="lg:hidden p-2 -ml-2 text-ink dark:text-dark-ink hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-ochre/10 flex items-center justify-center text-ochre">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<h1 className="text-xl sm:text-2xl font-serif font-medium text-ink dark:text-dark-ink">Analyses & Cartographie</h1>
|
||||
</div>
|
||||
<p className="text-[10px] text-concrete tracking-[0.25em] uppercase font-bold">Modèles sémantiques & clusters de connaissances</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between sm:justify-end gap-3">
|
||||
{/* Mobile Tab Switcher */}
|
||||
<div className="flex lg:hidden p-1 bg-black/5 dark:bg-white/5 rounded-xl self-center shrink-0">
|
||||
<button
|
||||
onClick={() => setViewMode('graph')}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all ${viewMode === 'graph' ? 'bg-white dark:bg-black text-ink shadow-sm' : 'text-concrete'}`}
|
||||
>
|
||||
Réseau Graphique
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('dashboard')}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all ${viewMode === 'dashboard' ? 'bg-white dark:bg-black text-ink shadow-sm' : 'text-concrete'}`}
|
||||
>
|
||||
Analyses & Ponts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={performAnalysis}
|
||||
disabled={isCalculating}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-102 active:scale-98 transition-all disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{isCalculating ? <RefreshCw size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||
{isCalculating ? 'Calcul...' : 'Re-analyser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: Interactive Canvas Network Graph View */}
|
||||
<div className={`flex-[1.4] p-6 relative ${viewMode === 'graph' ? 'block' : 'hidden lg:block'}`}>
|
||||
<NetworkGraph
|
||||
notes={notes}
|
||||
clusters={clusters}
|
||||
bridgeNotes={bridgeNotes}
|
||||
onNoteSelect={onNoteSelect}
|
||||
selectedClusterId={selectedClusterId}
|
||||
onClusterSelect={setSelectedClusterId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Insight Dashboard Column */}
|
||||
<div className={`flex-1 border-l border-border/20 flex flex-col h-full bg-[#fcfbfa] dark:bg-zinc-900/10 backdrop-blur-sm overflow-hidden ${viewMode === 'dashboard' ? 'flex' : 'hidden lg:flex'}`}>
|
||||
<div className="p-6 sm:p-8 flex-1 overflow-y-auto custom-scrollbar space-y-10">
|
||||
|
||||
{/* Active Cluster Inspection Drawer / Side Card */}
|
||||
<AnimatePresence>
|
||||
{selectedCluster && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="p-6 rounded-2xl bg-white dark:bg-zinc-800 border-2 border-ochre/30 shadow-md relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 left-0 w-2 h-full" style={{ backgroundColor: selectedCluster.color }} />
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<div className="space-y-1 pl-2">
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-ochre">Focus Cluster Activé</span>
|
||||
<h3 className="text-lg font-serif font-semibold text-ink dark:text-dark-ink">{selectedCluster.name}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedClusterId(null)}
|
||||
className="p-1 px-2.5 bg-black/5 dark:bg-white/5 hover:bg-black/10 text-xs font-bold rounded-lg uppercase tracking-wider transition-colors"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pl-2 space-y-3">
|
||||
<p className="text-xs text-concrete">Cet ensemble thématique réunit {selectedClusterNotes.length} notes complémentaires. Cliquez sur une note pour y accéder directement :</p>
|
||||
<div className="space-y-2 max-h-[180px] overflow-y-auto custom-scrollbar pr-1">
|
||||
{selectedClusterNotes.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => onNoteSelect(note.id)}
|
||||
className="w-full text-left p-2.5 rounded-lg bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 text-xs font-medium text-ink dark:text-dark-ink flex items-center justify-between gap-3 group transition-all"
|
||||
>
|
||||
<span className="truncate group-hover:translate-x-1 transition-transform">{note.title || 'Note sans titre'}</span>
|
||||
<ChevronRight size={12} className="text-concrete" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Stats Highlights Header */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-zinc-800/40 border border-border/40 shadow-sm flex flex-col justify-between">
|
||||
<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 Actifs</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl sm:text-2xl font-serif font-semibold text-ink dark:text-dark-ink">{clusters.length}</div>
|
||||
<p className="text-[9px] text-concrete font-medium uppercase mt-1">Détectés sans à priori</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-zinc-800/40 border border-border/40 shadow-sm flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-ochre mb-2">
|
||||
<Trophy size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Notes-Ponts</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl sm:text-2xl font-serif font-semibold text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
||||
<p className="text-[9px] text-concrete font-medium uppercase mt-1">Passerelles d'idées</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NEW SECTION: Auto Recalculator Control Dashboard Section */}
|
||||
<section className="p-5 rounded-2xl bg-white dark:bg-zinc-800 border border-border/40 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sliders size={15} className="text-ochre" />
|
||||
<h4 className="text-[11px] font-black uppercase tracking-[0.2em] text-ink dark:text-dark-ink">Système de Recalcul</h4>
|
||||
</div>
|
||||
<span className="flex items-center gap-1 text-[9.5px] font-bold text-emerald-500 uppercase">
|
||||
<CheckCircle2 size={11} /> Synchronisé
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[9px] text-concrete block">CRON PLANIFIÉ</span>
|
||||
<p className="text-xs text-ink dark:text-dark-ink font-semibold flex items-center gap-1.5">
|
||||
<Clock size={12} className="opacity-50" /> Quotidien (04:00)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[9px] text-concrete block">DERNIÈRE SYNCHRONISATION</span>
|
||||
<p className="text-xs text-ink dark:text-dark-ink font-bold font-mono">
|
||||
Aujourd'hui, {lastSyncTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recalcul Trigger Metrics */}
|
||||
<div className="pt-2 border-t border-border/10 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-[10px]">
|
||||
<span className="text-concrete">Notes éditées depuis recul :</span>
|
||||
<span className="font-bold font-mono text-ink dark:text-dark-ink">{notesModifiedCount} / 10 modifs</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-black/5 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-ochre/70"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(notesModifiedCount / 10) * 100}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[8px] text-concrete italic block">Le recalcul incrémental se déclenche automatiquement si modification de {'>'} 10 notes ou variation d'embeddings {'>'} 5%.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Isolated Clusters List */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle size={15} className="text-rose-400 opacity-80" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">Clusters Isolés ({isolatedClusters.length})</h3>
|
||||
</div>
|
||||
<span className="text-[9px] text-concrete italic">Sans points d'accroche</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{isolatedClusters.map(c => (
|
||||
<motion.div
|
||||
key={c.id}
|
||||
whileHover={{ y: -1 }}
|
||||
onClick={() => setSelectedClusterId(c.id)}
|
||||
className="p-3.5 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-black/10 flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
||||
<span className="text-xs font-medium text-ink dark:text-dark-ink">{c.name}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-rose-500 font-semibold uppercase tracking-wider bg-rose-500/5 px-2.5 py-0.5 rounded-full border border-rose-500/10">
|
||||
Non connecté
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
{isolatedClusters.length === 0 && (
|
||||
<div className="p-4 bg-white dark:bg-zinc-800 rounded-xl text-xs text-concrete text-center italic border border-border/20">
|
||||
Tous les clusters thématiques sont liés par au moins un point de passage sémantique !
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Bridge Notes Section */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Zap size={16} className="text-ochre" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">Notes-Ponts Influentes</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-zinc-800 border border-border/30 hover:border-ochre/40 hover:shadow-sm transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2 gap-4">
|
||||
<h4 className="text-xs font-semibold text-ink dark:text-dark-ink truncate flex-1 group-hover:text-ochre transition-colors">{bridge.title}</h4>
|
||||
<span className="text-[9.5px] font-bold text-ochre bg-ochre/5 border border-ochre/10 px-2.5 py-0.5 rounded-full">
|
||||
Lien : {(bridge.bridgeScore * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-1.5 border-t border-black/5 dark:border-white/5">
|
||||
{bridge.connectedClusterIds.map(cid => {
|
||||
const c = clusters.find(cl => cl.id === cid);
|
||||
return (
|
||||
<div
|
||||
key={cid}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedClusterId(cid);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2 py-0.5 bg-black/[0.02] dark:bg-white/[0.02] border border-border/30 rounded-md hover:border-concrete/40 transition-colors"
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c?.color }} />
|
||||
<span className="text-[9.5px] text-concrete font-medium uppercase tracking-wider">{c?.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{bridgeList.length === 0 && !isCalculating && (
|
||||
<div className="text-xs text-concrete italic text-center p-6 bg-white dark:bg-zinc-800 rounded-xl border border-border/20">
|
||||
Aucune note-pont significative n'a été détectée. Créez des notes transversales pour forger de nouveaux liens créatifs.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connection Suggestions */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Lightbulb size={16} className="text-indigo-500" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">Opportunités de Connexion (Ponts Suggérés)</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{suggestions.map((s) => (
|
||||
<div key={s.id} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 via-transparent to-transparent border border-indigo-500/10 hover:border-indigo-500/20 transition-all shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex -space-x-2 shrink-0">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[9px] text-white font-bold">A</div>
|
||||
<div className="w-5 h-5 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[9px] text-white font-bold">B</div>
|
||||
</div>
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-indigo-500/70 truncate">
|
||||
Relier {clusters.find(c => c.id === s.clusterAId)?.name} & {clusters.find(c => c.id === s.clusterBId)?.name}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold 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.5 bg-white/60 dark:bg-zinc-800 rounded-xl border border-border/20 text-[10.5px] italic text-concrete flex gap-2">
|
||||
<Zap size={13} className="shrink-0 text-ochre mt-0.5" />
|
||||
<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>
|
||||
)}
|
||||
{!isCalculating && suggestions.length === 0 && (
|
||||
<div className="text-xs text-concrete text-center italic p-6 border border-border/20 bg-white/40 dark:bg-zinc-800 rounded-xl">
|
||||
Toutes vos thématiques clés sont déjà formidablement interconnectées !
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2430
architectural-grid1/src/components/LandingPage.tsx
Normal file
2430
architectural-grid1/src/components/LandingPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1015
architectural-grid1/src/components/LandingPageV2.tsx
Normal file
1015
architectural-grid1/src/components/LandingPageV2.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1117
architectural-grid1/src/components/LandingPageV3.tsx
Normal file
1117
architectural-grid1/src/components/LandingPageV3.tsx
Normal file
File diff suppressed because it is too large
Load Diff
194
architectural-grid1/src/components/LivingBlock.tsx
Normal file
194
architectural-grid1/src/components/LivingBlock.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Zap, HelpCircle, ArrowRight, RefreshCw, Unlink, AlertCircle } from 'lucide-react';
|
||||
import { Note } from '../types';
|
||||
|
||||
interface LivingBlockProps {
|
||||
sourceNoteId: string;
|
||||
blockIndex: number;
|
||||
allNotes: Note[];
|
||||
hostNote: Note;
|
||||
onUpdateNote: (updatedNote: Note) => void;
|
||||
onOpenNote: (noteId: string) => void;
|
||||
wsConnected: boolean;
|
||||
broadcastLivingBlockUpdate?: (sourceNoteId: string, blockIndex: number, newText: string) => void;
|
||||
}
|
||||
|
||||
export const LivingBlock: React.FC<LivingBlockProps> = ({
|
||||
sourceNoteId,
|
||||
blockIndex,
|
||||
allNotes,
|
||||
hostNote,
|
||||
onUpdateNote,
|
||||
onOpenNote,
|
||||
wsConnected,
|
||||
broadcastLivingBlockUpdate
|
||||
}) => {
|
||||
const [pulse, setPulse] = useState(false);
|
||||
const pulseRef = useRef<any>(null);
|
||||
|
||||
// Locate source note and actual paragraph text
|
||||
const sourceNote = allNotes.find(n => n.id === sourceNoteId);
|
||||
const paragraphs = sourceNote?.content.split('\n') || [];
|
||||
const rawText = paragraphs[blockIndex];
|
||||
|
||||
// Store a local cache in standard state to support the "Source Deleted Snapshot" or local typing lag minimization
|
||||
const [localText, setLocalText] = useState(rawText || "Contenu de l'extrait sémantique.");
|
||||
const [isDeleted, setIsDeleted] = useState(!sourceNote || rawText === undefined);
|
||||
|
||||
// Sync state if source note or text updates from outside
|
||||
useEffect(() => {
|
||||
const isSourceMissing = !sourceNote || rawText === undefined;
|
||||
setIsDeleted(isSourceMissing);
|
||||
|
||||
if (!isSourceMissing && rawText !== localText) {
|
||||
setLocalText(rawText);
|
||||
}
|
||||
}, [rawText, sourceNote]);
|
||||
|
||||
// Handle pulse notification when custom update event is received from socket
|
||||
useEffect(() => {
|
||||
const handlePulseEvent = (e: any) => {
|
||||
if (e.detail && e.detail.sourceNoteId === sourceNoteId && e.detail.blockIndex === blockIndex) {
|
||||
setPulse(true);
|
||||
if (pulseRef.current) clearTimeout(pulseRef.current);
|
||||
pulseRef.current = setTimeout(() => {
|
||||
setPulse(false);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('living-block-pulse', handlePulseEvent);
|
||||
return () => {
|
||||
window.removeEventListener('living-block-pulse', handlePulseEvent);
|
||||
if (pulseRef.current) clearTimeout(pulseRef.current);
|
||||
};
|
||||
}, [sourceNoteId, blockIndex]);
|
||||
|
||||
// Edit body text and stream to central note and websockets
|
||||
const handleBodyTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newText = e.target.value;
|
||||
setLocalText(newText);
|
||||
|
||||
if (sourceNote && !isDeleted) {
|
||||
const updatedParagraphs = [...paragraphs];
|
||||
updatedParagraphs[blockIndex] = newText;
|
||||
const updatedSourceNote = {
|
||||
...sourceNote,
|
||||
content: updatedParagraphs.join('\n')
|
||||
};
|
||||
// 1. Update state
|
||||
onUpdateNote(updatedSourceNote);
|
||||
|
||||
// 2. Broadcast via WS connection to other terminals
|
||||
if (broadcastLivingBlockUpdate) {
|
||||
broadcastLivingBlockUpdate(sourceNoteId, blockIndex, newText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Convert Living Block to normal local text paragraph
|
||||
const handleConvertLocalText = () => {
|
||||
const hostParagraphs = hostNote.content.split('\n');
|
||||
// Find matching shortcode index
|
||||
const codeToSearch = `[[living-block:${sourceNoteId}:${blockIndex}]]`;
|
||||
const targetIdx = hostParagraphs.findIndex(line => line.trim() === codeToSearch);
|
||||
|
||||
if (targetIdx !== -1) {
|
||||
hostParagraphs[targetIdx] = localText; // Replace code with snapped plain text
|
||||
const updatedHostNote = {
|
||||
...hostNote,
|
||||
content: hostParagraphs.join('\n')
|
||||
};
|
||||
onUpdateNote(updatedHostNote);
|
||||
}
|
||||
};
|
||||
|
||||
// Styling helpers
|
||||
const borderStyle = isDeleted
|
||||
? 'border-rose-500/60 dark:border-red-900/60 bg-rose-50/20 dark:bg-rose-950/5'
|
||||
: !wsConnected
|
||||
? 'border-amber-500 dark:border-amber-700 bg-amber-50/10 dark:bg-amber-950/5'
|
||||
: pulse
|
||||
? 'border-blue-500 shadow-md shadow-blue-500/15 bg-blue-50/20 dark:bg-blue-950/10'
|
||||
: 'border-blue-500/80 bg-blue-50/5 dark:bg-blue-950/5';
|
||||
|
||||
return (
|
||||
<div className="group/block relative my-6">
|
||||
<div
|
||||
className={`w-full rounded-xl border-l-3 border-y border-r border-[#E8E6E3] dark:border-zinc-800 transition-all duration-300 overflow-hidden ${borderStyle}`}
|
||||
>
|
||||
{/* Header (20px) */}
|
||||
<div className="px-4.5 py-1.5 flex items-center justify-between bg-black/[0.015] dark:bg-white/[0.01] border-b border-black/[0.03] dark:border-white/[0.02]">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDeleted ? (
|
||||
<AlertCircle size={10} className="text-rose-500" />
|
||||
) : (
|
||||
<Zap size={10} className={wsConnected ? 'text-blue-500 fill-blue-500/20' : 'text-amber-500'} />
|
||||
)}
|
||||
<span className="text-[10px] font-sans font-medium text-concrete hover:text-ink transition-colors cursor-default max-w-[200px] truncate">
|
||||
{isDeleted ? "Source déconnectée" : sourceNote?.title || "Note connectée"}
|
||||
</span>
|
||||
|
||||
{/* Live syncing status badge */}
|
||||
{isDeleted ? (
|
||||
<span className="bg-rose-500/10 text-rose-600 dark:text-rose-400 font-bold px-1.5 py-0.2 rounded text-[8px] uppercase tracking-wider font-sans">
|
||||
DÉCONNECTÉ
|
||||
</span>
|
||||
) : wsConnected ? (
|
||||
<span className="bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-1.5 py-0.2 rounded text-[8px] uppercase tracking-wider font-sans animate-pulse">
|
||||
LIVE
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
title="Synchronisation suspendue"
|
||||
className="bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold px-1.5 py-0.2 rounded text-[8px] uppercase tracking-wider font-sans cursor-help"
|
||||
>
|
||||
HORS-LIGNE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isDeleted ? (
|
||||
<button
|
||||
onClick={handleConvertLocalText}
|
||||
className="text-[9.5px] font-bold text-rose-600 hover:text-rose-500 dark:text-rose-400 flex items-center gap-1 hover:underline transition-all"
|
||||
title="Détacher le bloc et le transformer en texte normal dans cette note"
|
||||
>
|
||||
<Unlink size={10} />
|
||||
Décharger le lien
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{!wsConnected && (
|
||||
<span className="text-[9px] text-amber-600 dark:text-amber-400 font-medium italic cursor-default">
|
||||
Synchro suspendue
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onOpenNote(sourceNoteId)}
|
||||
className="opacity-0 group-hover/block:opacity-100 flex items-center gap-1 text-[9.5px] font-extrabold text-blue-600 dark:text-blue-400 hover:underline transition-all"
|
||||
>
|
||||
Ouvrir <ArrowRight size={10} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body content editable block */}
|
||||
<div className="p-4 bg-blue-500/[0.015] dark:bg-blue-500/[0.005]">
|
||||
<textarea
|
||||
value={localText}
|
||||
onChange={handleBodyTextChange}
|
||||
disabled={isDeleted}
|
||||
rows={Math.max(2, Math.ceil(localText.length / 75))}
|
||||
className={`w-full bg-transparent border-none outline-none focus:ring-0 resize-none p-0 text-sm sm:text-base leading-relaxed text-ink/80 dark:text-dark-ink font-sans placeholder:text-concrete/20 ${isDeleted ? 'cursor-not-allowed opacity-80 select-all' : ''}`}
|
||||
placeholder="Écrivez le contenu du bloc dynamique ici..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2016
architectural-grid1/src/components/ModernBlockNoteEditor.tsx
Normal file
2016
architectural-grid1/src/components/ModernBlockNoteEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
237
architectural-grid1/src/components/NetworkGraph.tsx
Normal file
237
architectural-grid1/src/components/NetworkGraph.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
|
||||
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;
|
||||
selectedClusterId: string | null;
|
||||
onClusterSelect: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const NetworkGraph: React.FC<NetworkGraphProps> = ({
|
||||
notes,
|
||||
clusters,
|
||||
bridgeNotes,
|
||||
onNoteSelect,
|
||||
selectedClusterId,
|
||||
onClusterSelect
|
||||
}) => {
|
||||
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 ? 13 : 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(110))
|
||||
.force("charge", d3.forceManyBody().strength(-220))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collision", d3.forceCollide<D3Node>().radius(d => d.radius + 12));
|
||||
|
||||
// Links
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.data(links)
|
||||
.enter()
|
||||
.append("line")
|
||||
.attr("stroke", "#e2e8f0")
|
||||
.attr("stroke-opacity", (d: any) => {
|
||||
if (!selectedClusterId) return 0.6;
|
||||
const sId = typeof d.source === 'string' ? d.source : (d.source as any).id;
|
||||
const tId = typeof d.target === 'string' ? d.target : (d.target as any).id;
|
||||
const sourceNote = nodes.find(n => n.id === sId);
|
||||
const targetNote = nodes.find(n => n.id === tId);
|
||||
return (sourceNote?.clusterId === selectedClusterId && targetNote?.clusterId === selectedClusterId) ? 0.8 : 0.05;
|
||||
})
|
||||
.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 opacities based on focus
|
||||
node.attr("opacity", d => {
|
||||
if (!selectedClusterId) return 1;
|
||||
return d.clusterId === selectedClusterId ? 1 : 0.15;
|
||||
});
|
||||
|
||||
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.5 : 2)
|
||||
.style("filter", d => d.isBridge ? "drop-shadow(0 0 6px rgba(212, 175, 55, 0.6))" : "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})`);
|
||||
});
|
||||
|
||||
// Zoom transition on cluster highlight
|
||||
if (selectedClusterId && width && height) {
|
||||
const clusterNodes = nodes.filter(n => n.clusterId === selectedClusterId);
|
||||
if (clusterNodes.length > 0) {
|
||||
// Run a small tick count synchronously to find coordinates quickly if layout is starting
|
||||
for (let i = 0; i < 50; ++i) simulation.tick();
|
||||
|
||||
const xCoords = clusterNodes.map(cn => cn.x).filter((x): x is number => x !== undefined);
|
||||
const yCoords = clusterNodes.map(cn => cn.y).filter((y): y is number => y !== undefined);
|
||||
|
||||
if (xCoords.length > 0 && yCoords.length > 0) {
|
||||
const avgX = d3.mean(xCoords) || width / 2;
|
||||
const avgY = d3.mean(yCoords) || height / 2;
|
||||
|
||||
svg.transition()
|
||||
.duration(800)
|
||||
.call(
|
||||
zoom.transform,
|
||||
d3.zoomIdentity.translate(width / 2, height / 2).scale(1.4).translate(-avgX, -avgY)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
svg.transition()
|
||||
.duration(800)
|
||||
.call(zoom.transform, d3.zoomIdentity);
|
||||
}
|
||||
|
||||
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, selectedClusterId]);
|
||||
|
||||
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-2 max-w-[90%] sm:max-w-[450px]">
|
||||
{clusters.map(c => {
|
||||
const isSelected = selectedClusterId === c.id;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onClusterSelect?.(isSelected ? null : c.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border shadow-sm transition-all text-[9px] font-bold uppercase tracking-wider
|
||||
${isSelected
|
||||
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md'
|
||||
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c.color }} />
|
||||
<span>{c.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{selectedClusterId && (
|
||||
<button
|
||||
onClick={() => onClusterSelect?.(null)}
|
||||
className="px-3 py-1.5 rounded-full border border-rose-200 bg-rose-50 dark:bg-rose-950/20 dark:border-rose-900/40 text-rose-500 text-[9px] font-bold uppercase tracking-wider hover:bg-rose-100 transition-all shadow-sm"
|
||||
>
|
||||
Réinitialiser focus
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
769
architectural-grid1/src/components/NotebookInfoSidebar.tsx
Normal file
769
architectural-grid1/src/components/NotebookInfoSidebar.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
X,
|
||||
Clock,
|
||||
Folder,
|
||||
Calendar,
|
||||
FileText,
|
||||
Hash,
|
||||
Network,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Copy,
|
||||
Check,
|
||||
History,
|
||||
Info,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Note, Carnet } from '../types';
|
||||
|
||||
interface NotebookInfoSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
activeNote: Note | undefined;
|
||||
notes: Note[];
|
||||
carnets: Carnet[];
|
||||
onOpenNote: (id: string) => void;
|
||||
onUpdateNote?: (note: Note) => void;
|
||||
}
|
||||
|
||||
export const NotebookInfoSidebar: React.FC<NotebookInfoSidebarProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
activeNote,
|
||||
notes = [],
|
||||
carnets,
|
||||
onOpenNote,
|
||||
onUpdateNote
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = React.useState<'infos' | 'versions' | 'relations'>('infos');
|
||||
const [copiedId, setCopiedId] = React.useState(false);
|
||||
const [hoveredOrbitNode, setHoveredOrbitNode] = React.useState<any | null>(null);
|
||||
|
||||
// For ID copy action
|
||||
const handleCopyId = (id: string) => {
|
||||
navigator.clipboard.writeText(id).then(() => {
|
||||
setCopiedId(true);
|
||||
setTimeout(() => setCopiedId(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
// Explicit links for Network
|
||||
const explicitWikiLinks = React.useMemo(() => [
|
||||
{ source: 'n1', target: 'n1-b' },
|
||||
{ source: 'n3', target: 'n3-b' },
|
||||
{ source: 'bridge-1', target: 'n1' },
|
||||
{ source: 'bridge-1', target: 'n2' },
|
||||
], []);
|
||||
|
||||
const CARNET_COLOR_PALETTE: { [key: string]: string } = {
|
||||
'1': '#D97706', // Daily Notes - Warm Amber
|
||||
'2': '#059669', // Project: Neo - Soft Emerald
|
||||
'3': '#4F46E5', // Shared Docs - Rich Indigo
|
||||
'4': '#0891B2', // Architecture Research - Clean Cyan
|
||||
'5': '#EA580C', // History of Architecture - Deep Orange
|
||||
'6': '#DB2777', // Modernism - Vibrant Rose
|
||||
'7': '#65A30D', // Sustainable Design - Cool Lime
|
||||
};
|
||||
|
||||
const DEFAULT_CARNET_COLOR = '#71717A';
|
||||
|
||||
// Network calculation values
|
||||
const backlinks = React.useMemo(() => {
|
||||
if (!activeNote || !notes) return [];
|
||||
return notes.filter(n => {
|
||||
if (n.id === activeNote.id || n.isDeleted) return false;
|
||||
const isExplicit = explicitWikiLinks.some(link =>
|
||||
(link.source === n.id && link.target === activeNote.id)
|
||||
);
|
||||
const isContentLink = n.content.toLowerCase().includes(`[[${activeNote.title.toLowerCase()}]]`);
|
||||
return isExplicit || isContentLink;
|
||||
});
|
||||
}, [activeNote, notes, explicitWikiLinks]);
|
||||
|
||||
const outboundLinks = React.useMemo(() => {
|
||||
if (!activeNote || !notes) return [];
|
||||
return notes.filter(n => {
|
||||
if (n.id === activeNote.id || n.isDeleted) return false;
|
||||
const isExplicit = explicitWikiLinks.some(link =>
|
||||
(link.source === activeNote.id && link.target === n.id)
|
||||
);
|
||||
const isContentLink = activeNote.content.toLowerCase().includes(`[[${n.title.toLowerCase()}]]`);
|
||||
return isExplicit || isContentLink;
|
||||
});
|
||||
}, [activeNote, notes, explicitWikiLinks]);
|
||||
|
||||
const unlinkedMentions = React.useMemo(() => {
|
||||
if (!activeNote || !notes) return [];
|
||||
return notes.filter(n => {
|
||||
if (n.id === activeNote.id || n.isDeleted) return false;
|
||||
const isLinked = [...backlinks, ...outboundLinks].some(link => link.id === n.id);
|
||||
if (isLinked) return false;
|
||||
return n.content.toLowerCase().includes(activeNote.title.toLowerCase());
|
||||
});
|
||||
}, [activeNote, notes, backlinks, outboundLinks]);
|
||||
|
||||
const orbitNodes = React.useMemo(() => {
|
||||
const list: { id: string; title: string; color: string; carnetName: string; relationship: 'backlink' | 'outbound' | 'mention' }[] = [];
|
||||
|
||||
backlinks.forEach(n => {
|
||||
const carnet = carnets.find(c => c.id === n.carnetId);
|
||||
list.push({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR,
|
||||
carnetName: carnet?.name || 'Carnet',
|
||||
relationship: 'backlink'
|
||||
});
|
||||
});
|
||||
|
||||
outboundLinks.forEach(n => {
|
||||
const carnet = carnets.find(c => c.id === n.carnetId);
|
||||
list.push({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR,
|
||||
carnetName: carnet?.name || 'Carnet',
|
||||
relationship: 'outbound'
|
||||
});
|
||||
});
|
||||
|
||||
unlinkedMentions.forEach(n => {
|
||||
const carnet = carnets.find(c => c.id === n.carnetId);
|
||||
list.push({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR,
|
||||
carnetName: carnet?.name || 'Carnet',
|
||||
relationship: 'mention'
|
||||
});
|
||||
});
|
||||
|
||||
return list.slice(0, 8);
|
||||
}, [backlinks, outboundLinks, unlinkedMentions, carnets]);
|
||||
|
||||
const getSnippetWithHighlight = (content: string, term: string) => {
|
||||
const index = content.toLowerCase().indexOf(term.toLowerCase());
|
||||
if (index === -1) {
|
||||
return <span>{content.substring(0, 80)}...</span>;
|
||||
}
|
||||
const start = Math.max(0, index - 40);
|
||||
const end = Math.min(content.length, index + term.length + 40);
|
||||
const before = content.substring(start, index);
|
||||
const match = content.substring(index, index + term.length);
|
||||
const after = content.substring(index + term.length, end);
|
||||
return (
|
||||
<span>
|
||||
{start > 0 && "..."}
|
||||
{before}
|
||||
<mark className="bg-ochre/20 dark:bg-ochre/40 text-ochre px-1 py-0.5 rounded font-bold">{match}</mark>
|
||||
{after}
|
||||
{end < content.length && "..."}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Safe time calculation helper (mocked cleanly to match image's 'il y a 12 jours' or standard dynamic calculations)
|
||||
const getRelativeCreatedStr = (dateStr: string) => {
|
||||
if (dateStr.includes('12 mai 2026')) return 'il y a 12 jours';
|
||||
if (dateStr.includes('Oct 26')) return 'il y a 2h';
|
||||
if (dateStr.includes('Oct 27')) return 'il y a 1j';
|
||||
if (dateStr.includes('Oct 24')) return 'il y a 3j';
|
||||
if (dateStr.includes('Oct 25')) return 'il y a 2j';
|
||||
if (dateStr.includes('Oct 22')) return 'il y a 5j';
|
||||
if (dateStr.includes('Oct 23')) return 'il y a 4j';
|
||||
if (dateStr.includes('Oct 28')) return 'il y a 10 min';
|
||||
return 'il y a quelques jours';
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.aside
|
||||
initial={{ x: 380, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 380, opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 26, stiffness: 210 }}
|
||||
className="w-[380px] border-l border-border bg-[#F5F4F0] dark:bg-[#121212] shadow-xl flex flex-col z-50 shrink-0 relative h-full select-none"
|
||||
>
|
||||
{/* Header tabs row matching image style */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-[#F5F4F0]/85 dark:bg-[#121212]/85 backdrop-blur-md">
|
||||
<div className="flex gap-2.5">
|
||||
{/* Infos tab */}
|
||||
<button
|
||||
onClick={() => setActiveTab('infos')}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-bold tracking-wide transition-all duration-200 cursor-pointer
|
||||
${activeTab === 'infos'
|
||||
? 'bg-ink text-paper dark:bg-white dark:text-ink shadow-sm'
|
||||
: 'text-concrete hover:text-ink hover:bg-black/[0.03] dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<CheckCircle2 size={13} className={activeTab === 'infos' ? 'opacity-100' : 'opacity-70'} />
|
||||
<span>Infos</span>
|
||||
</button>
|
||||
|
||||
{/* Versions tab */}
|
||||
<button
|
||||
onClick={() => setActiveTab('versions')}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-bold tracking-wide transition-all duration-200 cursor-pointer
|
||||
${activeTab === 'versions'
|
||||
? 'bg-ink text-paper dark:bg-white dark:text-ink shadow-sm'
|
||||
: 'text-concrete hover:text-ink hover:bg-black/[0.03] dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<Clock size={13} className={activeTab === 'versions' ? 'opacity-100' : 'opacity-70'} />
|
||||
<span>Versions</span>
|
||||
</button>
|
||||
|
||||
{/* Network / Relations tab */}
|
||||
<button
|
||||
onClick={() => setActiveTab('relations')}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-bold tracking-wide transition-all duration-200 cursor-pointer
|
||||
${activeTab === 'relations'
|
||||
? 'bg-ink text-paper dark:bg-white dark:text-ink shadow-sm'
|
||||
: 'text-concrete hover:text-ink hover:bg-black/[0.03] dark:hover:bg-white/5'}`}
|
||||
>
|
||||
<Network size={13} className={activeTab === 'relations' ? 'opacity-100' : 'opacity-70'} />
|
||||
<span>Réseau</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 px-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-full text-concrete hover:text-ink transition-all cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Core scrollable content area */}
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar space-y-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{/* TABS - INFOS */}
|
||||
{activeTab === 'infos' && (
|
||||
<motion.div
|
||||
key="infos"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-6 text-left"
|
||||
>
|
||||
{activeNote ? (
|
||||
<div className="space-y-6 font-sans">
|
||||
{/* Calculated Stats */}
|
||||
{(() => {
|
||||
const wordCount = activeNote.content.trim() ? activeNote.content.trim().split(/\s+/).filter(Boolean).length : 0;
|
||||
const charCount = activeNote.content.length;
|
||||
const lineCount = activeNote.content.trim() ? activeNote.content.split('\n').length : 0;
|
||||
|
||||
// Count math equations
|
||||
const matchesBigMath = (activeNote.content.match(/\$\$[\s\S]*?\$\$/g) || []).length;
|
||||
const matchesInlineMath = (activeNote.content.match(/\$[^\$\n]+?\$/g) || []).length;
|
||||
const equationCount = matchesBigMath + matchesInlineMath;
|
||||
|
||||
// Count graph relations or internal visual blocks
|
||||
const matchesLivingBlocks = (activeNote.content.match(/\[\[living-block:.*?\]\]/g) || []).length;
|
||||
const graphCount = orbitNodes.length + matchesLivingBlocks;
|
||||
|
||||
// Count images
|
||||
const matchesMarkdownImages = (activeNote.content.match(/!\[.*?\]\(.*?\)/g) || []).length;
|
||||
const matchesHtmlImages = (activeNote.content.match(/<img\s+/g) || []).length;
|
||||
const imageCount = matchesMarkdownImages + matchesHtmlImages;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Grid Stats */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white/95 dark:bg-black/40 border border-border/50 rounded-2xl p-5 text-center flex flex-col justify-between min-h-[105px] shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<span className="block text-4xl font-medium font-serif text-ink dark:text-white tracking-tight leading-none">
|
||||
{wordCount}
|
||||
</span>
|
||||
<span className="text-[9.5px] font-bold uppercase tracking-[0.25em] text-muted-ink block mt-2">Mots</span>
|
||||
</div>
|
||||
<div className="bg-white/95 dark:bg-black/40 border border-border/50 rounded-2xl p-5 text-center flex flex-col justify-between min-h-[105px] shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<span className="block text-4xl font-medium font-serif text-ink dark:text-white tracking-tight leading-none">
|
||||
{charCount}
|
||||
</span>
|
||||
<span className="text-[9.5px] font-bold uppercase tracking-[0.25em] text-muted-ink block mt-2">Caractères</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary Detailed Counts Widget */}
|
||||
<div className="grid grid-cols-4 gap-1.5 bg-white/70 dark:bg-black/30 border border-border/50 rounded-2xl p-4 text-center shadow-xs">
|
||||
<div className="space-y-1">
|
||||
<span className="block text-base font-serif font-bold text-ink dark:text-white">{lineCount}</span>
|
||||
<span className="block text-[8px] font-extrabold uppercase tracking-widest text-concrete">Lignes</span>
|
||||
</div>
|
||||
<div className="space-y-1 border-l border-border/40">
|
||||
<span className="block text-base font-serif font-bold text-ink dark:text-white">{equationCount}</span>
|
||||
<span className="block text-[8px] font-extrabold uppercase tracking-widest text-concrete">Équations</span>
|
||||
</div>
|
||||
<div className="space-y-1 border-l border-border/40">
|
||||
<span className="block text-base font-serif font-bold text-ink dark:text-white">{graphCount}</span>
|
||||
<span className="block text-[8px] font-extrabold uppercase tracking-widest text-concrete">Graphes</span>
|
||||
</div>
|
||||
<div className="space-y-1 border-l border-border/40">
|
||||
<span className="block text-base font-serif font-bold text-ink dark:text-white">{imageCount}</span>
|
||||
<span className="block text-[8px] font-extrabold uppercase tracking-widest text-concrete">Images</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Attribute Detail rows styled to 100% exact layout matching the attached image */}
|
||||
<div className="space-y-5 bg-white/40 dark:bg-zinc-950/20 border border-border/50 rounded-2xl p-5 text-left select-text">
|
||||
{/* Carnet attribute */}
|
||||
<div className="flex items-start gap-4 pb-4 border-b border-border/30">
|
||||
<div className="p-2.5 bg-white dark:bg-neutral-900 border border-border/60 rounded-xl text-concrete shrink-0">
|
||||
<Folder size={15} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink block">Carnet</span>
|
||||
<span className="text-sm font-semibold text-ink dark:text-white">
|
||||
{carnets.find(c => c.id === activeNote.carnetId)?.name || "Général"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type attribute */}
|
||||
<div className="flex items-start gap-4 pb-4 border-b border-border/30">
|
||||
<div className="p-2.5 bg-white dark:bg-neutral-900 border border-border/60 rounded-xl text-concrete shrink-0">
|
||||
<FileText size={15} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink block">Type</span>
|
||||
<span className="text-sm font-semibold text-ink dark:text-white">
|
||||
{activeNote.isClipped ? 'Source Web' : 'Texte enrichi'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Créé le attribute */}
|
||||
<div className="flex items-start gap-4 pb-4 border-b border-border/30">
|
||||
<div className="p-2.5 bg-white dark:bg-neutral-900 border border-border/60 rounded-xl text-concrete shrink-0">
|
||||
<Calendar size={15} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink block">Créée le</span>
|
||||
<span className="text-sm font-semibold text-ink dark:text-white block">
|
||||
{activeNote.date || "12 mai 2026"}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-muted-ink block">
|
||||
{getRelativeCreatedStr(activeNote.date || "12 mai 2026")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modifiée attribute */}
|
||||
<div className="flex items-start gap-4 pb-4 border-b border-border/30">
|
||||
<div className="p-2.5 bg-white dark:bg-neutral-900 border border-border/60 rounded-xl text-concrete shrink-0">
|
||||
<Clock size={15} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink block">Modifiée</span>
|
||||
<span className="text-sm font-semibold text-ink dark:text-white block">
|
||||
{activeNote.date || "12 mai 2026"} • 15:58
|
||||
</span>
|
||||
<span className="text-[10.5px] text-muted-ink block">
|
||||
{getRelativeCreatedStr(activeNote.date || "12 mai 2026")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ID attribute */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2.5 bg-white dark:bg-neutral-900 border border-border/60 rounded-xl text-concrete shrink-0">
|
||||
<Hash size={15} />
|
||||
</div>
|
||||
<div className="space-y-0.5 min-w-0 flex-1">
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink block">ID</span>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[11px] font-mono text-muted-ink truncate block select-all" title={activeNote.id}>
|
||||
{activeNote.id}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopyId(activeNote.id)}
|
||||
className="p-1 hover:bg-slate-100 dark:hover:bg-neutral-800 rounded text-concrete shrink-0 transition-all cursor-pointer"
|
||||
title="Copier l'ID de la note"
|
||||
>
|
||||
{copiedId ? <Check size={11} className="text-emerald-500" /> : <Copy size={11} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snapshots Toggle */}
|
||||
<div className="bg-white/50 dark:bg-neutral-900/40 border border-border/50 rounded-2xl p-5 flex items-center justify-between group hover:shadow-sm transition-all duration-300">
|
||||
<div className="flex items-center gap-3.5 text-left">
|
||||
<div className="p-2.5 bg-paper dark:bg-neutral-850 rounded-xl text-ochre border border-ochre/10">
|
||||
<History size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-ink dark:text-white">Snapshots Actifs</h4>
|
||||
<p className="text-[10px] text-muted-ink leading-relaxed">Suivi d'historique automatique</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={activeNote.isVersioningEnabled !== false}
|
||||
onChange={() => {
|
||||
onUpdateNote?.({
|
||||
...activeNote,
|
||||
isVersioningEnabled: activeNote.isVersioningEnabled === false ? true : false
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="w-10 h-5.5 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[18px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-3.5 after:w-3.5 after:transition-all duration-300 ease-in-out peer-checked:bg-ink dark:peer-checked:bg-white"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-ink/40">
|
||||
<Folder size={36} className="mx-auto mb-3 opacity-30 text-concrete" />
|
||||
<p className="text-xs font-serif italic">Veuillez sélectionner une note pour inspecter ses informations.</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* TABS - VERSIONS */}
|
||||
{activeTab === 'versions' && (
|
||||
<motion.div
|
||||
key="versions"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-6 text-left"
|
||||
>
|
||||
<div className="flex items-center justify-between pl-1">
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink">Snapshots & Versions</h4>
|
||||
<span className="text-[9px] font-mono text-muted-ink bg-black/5 dark:bg-white/5 px-2 py-0.5 rounded-full">
|
||||
{(activeNote?.versionHistory || []).length} Snapshots
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activeNote ? (
|
||||
<div className="space-y-5">
|
||||
{activeNote.isVersioningEnabled !== false ? (
|
||||
<>
|
||||
{/* Banner to snap manual version */}
|
||||
<div className="p-4 bg-ochre/5 dark:bg-neutral-900 border border-ochre/20 rounded-xl space-y-3">
|
||||
<div className="text-left space-y-0.5">
|
||||
<span className="text-[10px] text-ochre uppercase font-bold tracking-widest block">Garnir l'historique</span>
|
||||
<p className="text-[10px] text-muted-ink leading-relaxed">Figer manuellement l'état actuel de la note.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newSnapshot = {
|
||||
id: 'v-' + Date.now(),
|
||||
title: activeNote.title,
|
||||
content: activeNote.content,
|
||||
timestamp: new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' }) + ' • ' + new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
|
||||
size: activeNote.content.length
|
||||
};
|
||||
onUpdateNote?.({
|
||||
...activeNote,
|
||||
versionHistory: [newSnapshot, ...(activeNote.versionHistory || [])]
|
||||
});
|
||||
}}
|
||||
className="w-full text-center py-2 bg-ink dark:bg-white hover:opacity-90 text-paper dark:text-ink text-[10px] uppercase tracking-widest font-bold rounded-lg transition-all shadow-sm cursor-pointer"
|
||||
>
|
||||
Figer un instant
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Snapshot list */}
|
||||
<div className="space-y-3">
|
||||
{(activeNote.versionHistory || []).length > 0 ? (
|
||||
<div className="space-y-3 max-h-[440px] overflow-y-auto custom-scrollbar pr-1">
|
||||
{(activeNote.versionHistory || []).map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="p-4 bg-white dark:bg-zinc-950 border border-border hover:border-accent/40 rounded-xl space-y-2.5 transition-all shadow-xs"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-0.5 text-left">
|
||||
<span className="text-xs uppercase tracking-wide font-bold text-ink dark:text-white block truncate max-w-[190px]">
|
||||
{v.title}
|
||||
</span>
|
||||
<span className="text-[9.5px] text-muted-ink block">{v.timestamp}</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-muted-ink bg-slate-100 dark:bg-neutral-850 px-1.5 py-0.5 rounded">
|
||||
{v.size >= 1024 ? (v.size / 1024).toFixed(1) + ' KB' : v.size + ' B'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3.5 pt-2 border-t border-black/[0.03] dark:border-white/[0.02] text-[10px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
alert(`Aperçu de la version "${v.title}" :\n\n${v.content || "Note vide"}`);
|
||||
}}
|
||||
className="text-muted-ink hover:text-ink transition-colors font-semibold"
|
||||
>
|
||||
Aperçu
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm("Êtes-vous sûr de vouloir restaurer cette version ? Le contenu actuel sera archivé comme nouvelle version.")) {
|
||||
const backupSnapshot = {
|
||||
id: 'v-' + Date.now(),
|
||||
title: activeNote.title,
|
||||
content: activeNote.content,
|
||||
timestamp: new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' }) + ' • ' + new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
|
||||
size: activeNote.content.length
|
||||
};
|
||||
onUpdateNote?.({
|
||||
...activeNote,
|
||||
title: v.title,
|
||||
content: v.content,
|
||||
versionHistory: [backupSnapshot, ...(activeNote.versionHistory || []).filter(h => h.id !== v.id)]
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-ochre dark:text-ochre font-bold hover:underline"
|
||||
>
|
||||
Restaurer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 px-6 border border-dashed border-border/80 bg-white/45 rounded-xl text-muted-ink/50">
|
||||
<Clock size={24} className="mx-auto mb-2 opacity-30 text-concrete" />
|
||||
<p className="text-[11px] font-medium leading-relaxed">Aucun snapshot enregistré pour le moment. Modifiez la note pour démarrer le suivi ou figez-en un manuellement.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 px-6 border-2 border-dashed border-border/60 rounded-2xl bg-amber-500/5 border-amber-500/10 text-amber-600 space-y-3">
|
||||
<AlertCircle size={28} className="mx-auto opacity-70" />
|
||||
<h5 className="font-bold text-xs uppercase tracking-wider">Suivi d'historique inactif</h5>
|
||||
<p className="text-[10px] leading-relaxed text-concrete">L'historique des versions est actuellement désactivé pour cette note spécifique. Pour l'activer, cochez l'option dans l'onglet "Infos".</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-ink/40">
|
||||
<Clock size={36} className="mx-auto mb-3 opacity-30 text-concrete" />
|
||||
<p className="text-xs font-serif italic">Veuillez sélectionner une note pour voir son historique de versions.</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* TABS - RELATIONS (RESEAU) */}
|
||||
{activeTab === 'relations' && (
|
||||
<motion.div
|
||||
key="relations"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-6 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink whitespace-nowrap">Vue Graphe Locale</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
{activeNote ? (
|
||||
<>
|
||||
{/* Interactive Local Graph representation */}
|
||||
<div className="relative p-2 bg-white/80 dark:bg-black/30 border border-border/60 rounded-xl overflow-hidden shadow-inner flex flex-col items-center">
|
||||
<svg width="100%" height="220" viewBox="0 0 320 220" className="select-none font-sans">
|
||||
<defs>
|
||||
<filter id="glow-panel-sidebar-three" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Dotted boundary */}
|
||||
<circle cx="160" cy="110" r="70" fill="none" stroke="#E2E8F0" strokeWidth="1" strokeDasharray="3,6" className="dark:stroke-neutral-800" />
|
||||
|
||||
{/* Links */}
|
||||
{orbitNodes.map((node, i) => {
|
||||
const angle = i * (orbitNodes.length > 0 ? (2 * Math.PI) / orbitNodes.length : 0);
|
||||
const nx = 160 + 70 * Math.cos(angle);
|
||||
const ny = 110 + 62 * Math.sin(angle);
|
||||
return (
|
||||
<g key={node.id}>
|
||||
<line
|
||||
x1="160"
|
||||
y1="110"
|
||||
x2={nx}
|
||||
y2={ny}
|
||||
stroke={node.relationship === 'mention' ? '#94A3B8' : '#A47148'}
|
||||
strokeWidth={node.relationship === 'mention' ? 1.2 : 2}
|
||||
strokeDasharray={node.relationship === 'mention' ? '3,3' : 'none'}
|
||||
className="opacity-50 transition-all hover:opacity-100"
|
||||
/>
|
||||
{node.relationship === 'outbound' && (
|
||||
<polygon
|
||||
points={`${160 + (nx - 160) * 0.75},${110 + (ny - 110) * 0.75} ${160 + (nx - 160) * 0.75 - 4},${110 + (ny - 110) * 0.75 - 4} ${160 + (nx - 160) * 0.75 - 4},${110 + (ny - 110) * 0.75 + 4}`}
|
||||
transform={`rotate(${(angle * 180) / Math.PI}, ${160 + (nx - 160) * 0.75}, ${110 + (ny - 110) * 0.75})`}
|
||||
fill="#A47148"
|
||||
className="opacity-70"
|
||||
/>
|
||||
)}
|
||||
{node.relationship === 'backlink' && (
|
||||
<polygon
|
||||
points={`${160 + (nx - 160) * 0.3},${110 + (ny - 110) * 0.3} ${160 + (nx - 160) * 0.3 - 4},${110 + (ny - 110) * 0.3 - 4} ${160 + (nx - 160) * 0.3 - 4},${110 + (ny - 110) * 0.3 + 4}`}
|
||||
transform={`rotate(${((angle + Math.PI) * 180) / Math.PI}, ${160 + (nx - 160) * 0.3}, ${110 + (ny - 110) * 0.3})`}
|
||||
fill="#A47148"
|
||||
className="opacity-70"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Center Node: Active Note */}
|
||||
<g>
|
||||
<circle
|
||||
cx="160"
|
||||
cy="110"
|
||||
r="15"
|
||||
fill="#A47148"
|
||||
className="stroke-white dark:stroke-black stroke-[3px] shadow transition-transform duration-300 hover:scale-110 active:scale-95 cursor-pointer"
|
||||
/>
|
||||
<circle cx="160" cy="110" r="5" fill="#FFFFFF" />
|
||||
</g>
|
||||
|
||||
{/* Orbit nodes */}
|
||||
{orbitNodes.map((node, i) => {
|
||||
const angle = i * (orbitNodes.length > 0 ? (2 * Math.PI) / orbitNodes.length : 0);
|
||||
const nx = 160 + 70 * Math.cos(angle);
|
||||
const ny = 110 + 62 * Math.sin(angle);
|
||||
const isHovered = hoveredOrbitNode?.id === node.id;
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
className="cursor-pointer group"
|
||||
onClick={() => onOpenNote(node.id)}
|
||||
onMouseEnter={() => setHoveredOrbitNode(node)}
|
||||
onMouseLeave={() => setHoveredOrbitNode(null)}
|
||||
>
|
||||
<circle
|
||||
cx={nx}
|
||||
cy={ny}
|
||||
r={isHovered ? 11 : 8}
|
||||
fill={node.color}
|
||||
stroke={isHovered ? '#000000' : '#FFFFFF'}
|
||||
strokeWidth={1.5}
|
||||
className="transition-all duration-200 group-hover:shadow"
|
||||
/>
|
||||
<text
|
||||
x={nx}
|
||||
y={ny + 15}
|
||||
textAnchor="middle"
|
||||
fontSize="8"
|
||||
className="fill-concrete bg-white font-medium select-none pointer-events-none opacity-40 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{node.title.substring(0, 10)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div className="absolute bottom-2 left-2 right-2 p-2 bg-white/90 dark:bg-black/95 rounded-lg border border-border/40 text-left min-h-[46px] select-text">
|
||||
{hoveredOrbitNode ? (
|
||||
<div className="animate-fadeIn">
|
||||
<div className="flex justify-between items-center text-[8px] text-muted-ink uppercase tracking-wider">
|
||||
<span>{hoveredOrbitNode.carnetName}</span>
|
||||
<span className="font-bold">
|
||||
{hoveredOrbitNode.relationship === 'backlink' ? 'Lien Entrant' : hoveredOrbitNode.relationship === 'outbound' ? 'Lien Sortant' : 'Mention Simple'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-bold text-ink dark:text-white truncate text-xs">{hoveredOrbitNode.title}</p>
|
||||
<p className="text-[9px] text-muted-ink italic">Cliquez pour ouvrir la note</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-1 text-muted-ink/60 text-[10px] font-medium leading-normal flex items-center justify-center gap-1.5">
|
||||
<Network size={12} className="text-muted-ink/40" />
|
||||
Survolez un nœud, cliquez pour ouvrir
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explicit links listings with highlighting */}
|
||||
<div className="space-y-4 pt-2 font-sans text-left">
|
||||
{/* 1. Backlinks */}
|
||||
<div className="space-y-1.5">
|
||||
<h5 className="text-[10px] uppercase tracking-[0.18em] font-bold text-muted-ink">
|
||||
Liens Entrants ({backlinks.length})
|
||||
</h5>
|
||||
{backlinks.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[160px] overflow-y-auto custom-scrollbar pr-1">
|
||||
{backlinks.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => onOpenNote(n.id)}
|
||||
className="p-3 bg-white dark:bg-zinc-950 border border-border hover:border-accent/40 rounded-xl cursor-pointer transition-all space-y-1.5 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between text-muted-ink font-sans">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-ink dark:text-white truncate max-w-[180px]">{n.title}</span>
|
||||
<span className="text-[8px] bg-accent/5 text-accent/80 px-1.5 py-0.5 rounded-full font-bold uppercase tracking-tight">Réf</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-ink/70 dark:text-white/70 italic leading-snug select-text">
|
||||
{getSnippetWithHighlight(n.content, activeNote.title)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-ink leading-normal italic bg-white/45 p-3 rounded-xl border border-border/40">Aucun lien entrant de type wiki [[lien]] pointant vers cette note.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. Outbound Links */}
|
||||
<div className="space-y-1.5">
|
||||
<h5 className="text-[10px] uppercase tracking-[0.18em] font-bold text-muted-ink">
|
||||
Liens Sortants ({outboundLinks.length})
|
||||
</h5>
|
||||
{outboundLinks.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[160px] overflow-y-auto custom-scrollbar pr-1">
|
||||
{outboundLinks.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => onOpenNote(n.id)}
|
||||
className="p-3 bg-white dark:bg-zinc-950 border border-border hover:border-accent/40 rounded-xl cursor-pointer transition-all space-y-1.5 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between text-muted-ink font-sans">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-ink dark:text-white truncate max-w-[180px]">{n.title}</span>
|
||||
<span className="text-[8px] bg-ochre/5 text-ochre/80 px-1.5 py-0.5 rounded-full font-bold uppercase tracking-tight">Vers</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-ink/70 dark:text-white/70 italic leading-snug select-text">
|
||||
{getSnippetWithHighlight(activeNote.content, n.title)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-ink leading-normal italic bg-white/45 p-3 rounded-xl border border-border/40">Cette note ne pointe vers aucune autre note de type [[lien]].</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-ink/40">
|
||||
<Network size={36} className="mx-auto mb-3 opacity-30 text-concrete" />
|
||||
<p className="text-xs font-serif italic">Sélectionnez une note pour analyser son graphe local.</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
1532
architectural-grid1/src/components/NotebooksView.tsx
Normal file
1532
architectural-grid1/src/components/NotebooksView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
665
architectural-grid1/src/components/RevisionView.tsx
Normal file
665
architectural-grid1/src/components/RevisionView.tsx
Normal file
@@ -0,0 +1,665 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
GraduationCap,
|
||||
Layers,
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RotateCcw,
|
||||
CheckCircle2,
|
||||
X,
|
||||
Inbox,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
Award
|
||||
} from 'lucide-react';
|
||||
import { Note, Flashcard, FlashcardDeck, FlashcardEvaluation } from '../types';
|
||||
|
||||
interface RevisionViewProps {
|
||||
notes: Note[];
|
||||
flashcards: Flashcard[];
|
||||
onUpdateFlashcards: (updated: Flashcard[]) => void;
|
||||
onSelectNote: (noteId: string) => void;
|
||||
onOpenSidebar?: () => void;
|
||||
initialActiveDeckId?: string | null;
|
||||
onClearActiveDeckId?: () => void;
|
||||
}
|
||||
|
||||
export const RevisionView: React.FC<RevisionViewProps> = ({
|
||||
notes,
|
||||
flashcards,
|
||||
onUpdateFlashcards,
|
||||
onSelectNote,
|
||||
onOpenSidebar,
|
||||
initialActiveDeckId,
|
||||
onClearActiveDeckId
|
||||
}) => {
|
||||
// Active states
|
||||
const [activeDeckId, setActiveDeckId] = useState<string | null>(initialActiveDeckId || null);
|
||||
const [isSessionActive, setIsSessionActive] = useState(false);
|
||||
const [isSessionFinished, setIsSessionFinished] = useState(false);
|
||||
|
||||
// Active review states
|
||||
const [currentCardIndex, setCurrentCardIndex] = useState(0);
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
const [sessionCards, setSessionCards] = useState<Flashcard[]>([]);
|
||||
const [sessionHistory, setSessionHistory] = useState<Record<string, FlashcardEvaluation>>({});
|
||||
const [onlyFailedCardsSession, setOnlyFailedCardsSession] = useState(false);
|
||||
|
||||
// Sync initial deck selection from outer prop/reminder
|
||||
useEffect(() => {
|
||||
if (initialActiveDeckId) {
|
||||
setActiveDeckId(initialActiveDeckId);
|
||||
// Auto-trigger session
|
||||
const deckCards = flashcards.filter(c => c.noteId === initialActiveDeckId);
|
||||
if (deckCards.length > 0) {
|
||||
setSessionCards([...deckCards]);
|
||||
setCurrentCardIndex(0);
|
||||
setIsFlipped(false);
|
||||
setIsSessionActive(true);
|
||||
setIsSessionFinished(false);
|
||||
setSessionHistory({});
|
||||
}
|
||||
}
|
||||
}, [initialActiveDeckId, flashcards]);
|
||||
|
||||
// Compute Decks based on current flashcards and notes
|
||||
const decks = useMemo(() => {
|
||||
const deckMap = new Map<string, Flashcard[]>();
|
||||
flashcards.forEach(card => {
|
||||
if (!deckMap.has(card.noteId)) {
|
||||
deckMap.set(card.noteId, []);
|
||||
}
|
||||
deckMap.get(card.noteId)!.push(card);
|
||||
});
|
||||
|
||||
const list: FlashcardDeck[] = [];
|
||||
deckMap.forEach((cardsInDeck, noteId) => {
|
||||
const parentNote = notes.find(n => n.id === noteId);
|
||||
if (!parentNote || parentNote.isDeleted) return;
|
||||
|
||||
// Find min nextReviewDate
|
||||
let minDate = cardsInDeck[0]?.nextReviewDate || new Date().toISOString();
|
||||
cardsInDeck.forEach(c => {
|
||||
if (c.nextReviewDate < minDate) {
|
||||
minDate = c.nextReviewDate;
|
||||
}
|
||||
});
|
||||
|
||||
// Mastery score: portion of mastered/sure cards in last evaluation
|
||||
const totalCards = cardsInDeck.length;
|
||||
let masteredCount = 0;
|
||||
cardsInDeck.forEach(c => {
|
||||
if (c.mastered) masteredCount++;
|
||||
});
|
||||
|
||||
list.push({
|
||||
noteId,
|
||||
title: parentNote.title,
|
||||
cardsCount: totalCards,
|
||||
nextReviewDate: minDate,
|
||||
masteryScore: totalCards > 0 ? masteredCount / totalCards : 0,
|
||||
cards: cardsInDeck
|
||||
});
|
||||
});
|
||||
|
||||
// Sort decks: first those that need review (past nextReviewDate), then alphabetical
|
||||
const nowStr = new Date().toISOString();
|
||||
return list.sort((a, b) => {
|
||||
const aNeeds = a.nextReviewDate <= nowStr;
|
||||
const bNeeds = b.nextReviewDate <= nowStr;
|
||||
if (aNeeds && !bNeeds) return -1;
|
||||
if (!aNeeds && bNeeds) return 1;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}, [flashcards, notes]);
|
||||
|
||||
const activeDeck = useMemo(() => {
|
||||
return decks.find(d => d.noteId === activeDeckId);
|
||||
}, [decks, activeDeckId]);
|
||||
|
||||
// Launch review session for a deck
|
||||
const handleStartReview = (noteId: string, failedOnly = false) => {
|
||||
const deck = decks.find(d => d.noteId === noteId);
|
||||
if (!deck) return;
|
||||
|
||||
let cardsToReview = [...deck.cards];
|
||||
if (failedOnly) {
|
||||
// Filter for cards graded as 'fail' or 'hesitant' in session history, or simply subset of session cards
|
||||
const failedIds = Object.keys(sessionHistory).filter(id => sessionHistory[id] === 'fail');
|
||||
cardsToReview = deck.cards.filter(c => failedIds.includes(c.id));
|
||||
if (cardsToReview.length === 0) {
|
||||
// Fallback to active session's rated fail
|
||||
cardsToReview = deck.cards.filter(c => sessionHistory[c.id] === 'fail');
|
||||
}
|
||||
setOnlyFailedCardsSession(true);
|
||||
} else {
|
||||
setOnlyFailedCardsSession(false);
|
||||
}
|
||||
|
||||
if (cardsToReview.length === 0) return;
|
||||
|
||||
// Shuffle cards for better learning cognitive effect
|
||||
const shuffled = [...cardsToReview].sort(() => Math.random() - 0.5);
|
||||
|
||||
setActiveDeckId(noteId);
|
||||
setSessionCards(shuffled);
|
||||
setCurrentCardIndex(0);
|
||||
setIsFlipped(false);
|
||||
setIsSessionActive(true);
|
||||
setIsSessionFinished(false);
|
||||
setSessionHistory({});
|
||||
};
|
||||
|
||||
const handleCardFlip = () => {
|
||||
setIsFlipped(!isFlipped);
|
||||
};
|
||||
|
||||
// Keyboard support during review
|
||||
useEffect(() => {
|
||||
if (!isSessionActive || isSessionFinished) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
handleCardFlip();
|
||||
} else if (isFlipped) {
|
||||
if (e.key === '1') {
|
||||
handleEvaluate('fail');
|
||||
} else if (e.key === '2') {
|
||||
handleEvaluate('hesitant');
|
||||
} else if (e.key === '3') {
|
||||
handleEvaluate('sure');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isSessionActive, isSessionFinished, isFlipped, currentCardIndex, sessionCards]);
|
||||
|
||||
// Simple Spaced Repetition Logic (Leitner system variation)
|
||||
const handleEvaluate = (evaluation: FlashcardEvaluation) => {
|
||||
const currentCard = sessionCards[currentCardIndex];
|
||||
if (!currentCard) return;
|
||||
|
||||
// Record evaluation in session context
|
||||
setSessionHistory(prev => ({
|
||||
...prev,
|
||||
[currentCard.id]: evaluation
|
||||
}));
|
||||
|
||||
// Calculate new intervals
|
||||
let interval = currentCard.intervalDays || 1;
|
||||
let ease = currentCard.easeFactor || 2.5;
|
||||
let mastered = currentCard.mastered || false;
|
||||
|
||||
if (evaluation === 'fail') {
|
||||
interval = 1; // back to review tomorrow
|
||||
ease = Math.max(1.3, ease - 0.2);
|
||||
mastered = false;
|
||||
} else if (evaluation === 'hesitant') {
|
||||
interval = Math.max(2, Math.floor(interval * 1.2));
|
||||
mastered = false;
|
||||
} else { // sure
|
||||
interval = Math.ceil(interval * ease);
|
||||
ease = Math.min(3.5, ease + 0.15);
|
||||
mastered = true;
|
||||
}
|
||||
|
||||
// Calculate next review date
|
||||
const nextDate = new Date();
|
||||
nextDate.setDate(nextDate.getDate() + interval);
|
||||
|
||||
// Build historical entry
|
||||
const historyItem = {
|
||||
reviewedAt: new Date().toISOString(),
|
||||
evaluation
|
||||
};
|
||||
|
||||
const updatedCard: Flashcard = {
|
||||
...currentCard,
|
||||
intervalDays: interval,
|
||||
nextReviewDate: nextDate.toISOString(),
|
||||
easeFactor: ease,
|
||||
mastered,
|
||||
history: [...(currentCard.history || []), historyItem]
|
||||
};
|
||||
|
||||
// Propagate up to global storage
|
||||
const updatedGlobal = flashcards.map(c => c.id === currentCard.id ? updatedCard : c);
|
||||
onUpdateFlashcards(updatedGlobal);
|
||||
|
||||
// Update in-place session cards to preserve intermediate updates
|
||||
setSessionCards(prev => prev.map((c, i) => i === currentCardIndex ? updatedCard : c));
|
||||
|
||||
// Progress flow
|
||||
if (currentCardIndex < sessionCards.length - 1) {
|
||||
setTimeout(() => {
|
||||
setCurrentCardIndex(prev => prev + 1);
|
||||
setIsFlipped(false);
|
||||
}, 300);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setIsSessionFinished(true);
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentCardIndex < sessionCards.length - 1) {
|
||||
setCurrentCardIndex(currentCardIndex + 1);
|
||||
setIsFlipped(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentCardIndex > 0) {
|
||||
setCurrentCardIndex(currentCardIndex - 1);
|
||||
setIsFlipped(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExitSession = () => {
|
||||
setIsSessionActive(false);
|
||||
setIsSessionFinished(false);
|
||||
onClearActiveDeckId?.();
|
||||
};
|
||||
|
||||
// Statistics summaries
|
||||
const finishedStats = useMemo(() => {
|
||||
if (sessionCards.length === 0) return { sureCount: 0, hesitantCount: 0, failCount: 0, percentage: 0 };
|
||||
|
||||
let sureCount = 0;
|
||||
let hesitantCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
sessionCards.forEach(c => {
|
||||
const evaluation = sessionHistory[c.id];
|
||||
if (evaluation === 'sure') sureCount++;
|
||||
else if (evaluation === 'hesitant') hesitantCount++;
|
||||
else if (evaluation === 'fail') failCount++;
|
||||
});
|
||||
|
||||
const totalRated = Object.keys(sessionHistory).length || 1;
|
||||
const percentage = Math.round((sureCount / totalRated) * 100);
|
||||
|
||||
return {
|
||||
sureCount,
|
||||
hesitantCount,
|
||||
failCount,
|
||||
percentage
|
||||
};
|
||||
}, [sessionCards, sessionHistory]);
|
||||
|
||||
const formattingDate = (isoStr: string) => {
|
||||
const diff = new Date(isoStr).getTime() - Date.now();
|
||||
if (diff <= 0) return 'Dû aujourd\'hui';
|
||||
const days = Math.ceil(diff / (24 * 60 * 60 * 1000));
|
||||
return `Dans ${days}j`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white dark:bg-dark-paper overflow-y-auto w-full transition-colors duration-500">
|
||||
|
||||
{/* 1. Header Toolbar */}
|
||||
<div className="px-6 sm:px-12 py-6 flex items-center justify-between sticky top-0 bg-white/90 dark:bg-dark-paper/90 backdrop-blur-sm z-40 border-b border-border gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{onOpenSidebar && (
|
||||
<button
|
||||
onClick={onOpenSidebar}
|
||||
className="lg:hidden p-2 -ml-2 text-ink dark:text-dark-ink hover:bg-black/5 rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isSessionActive ? (
|
||||
<button
|
||||
onClick={handleExitSession}
|
||||
className="flex items-center gap-2 text-concrete hover:text-ink dark:text-dark-concrete dark:hover:text-dark-ink transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span className="text-xs font-bold uppercase tracking-widest">Abandonner</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<GraduationCap className="text-accent shrink-0" size={20} />
|
||||
<h2 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Focal de Révision</h2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSessionActive && activeDeck && (
|
||||
<div className="text-[11px] font-mono bg-paper dark:bg-dark-paper text-concrete border border-border px-3 py-1.5 rounded-full lowercase tracking-wider">
|
||||
deck : <span className="font-bold text-accent">{activeDeck.title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. Main Display Area */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 md:p-12 max-w-5xl mx-auto w-full">
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
{/* SCREEN A: Decks Collection list view */}
|
||||
{!isSessionActive && (
|
||||
<motion.div
|
||||
key="decks-list"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
className="w-full space-y-10"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-serif font-black text-ink dark:text-dark-ink">Decks de Révision Active</h1>
|
||||
<p className="text-sm font-light text-muted-ink dark:text-dark-muted max-w-2xl">
|
||||
Révisez vos connaissances de manière ciblée grâce au système d'espacement algorithmique Leitner. L’apprentissage actif commence ici.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{decks.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{decks.map(deck => {
|
||||
const nowStr = new Date().toISOString();
|
||||
const dueCount = deck.cards.filter(c => c.nextReviewDate <= nowStr).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deck.noteId}
|
||||
id={`deck-card-${deck.noteId}`}
|
||||
className="p-6 bg-[#FCFCFA] dark:bg-white/[0.02] border border-border/60 hover:border-accent/40 rounded-2xl flex flex-col justify-between gap-5 transition-all shadow-sm hover:shadow-xs relative group"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<div className="space-y-1 truncate">
|
||||
<h3 className="text-lg font-serif font-semibold text-ink dark:text-dark-ink truncate group-hover:text-accent transition-colors">
|
||||
{deck.title}
|
||||
</h3>
|
||||
<p className="text-xs text-concrete flex items-center gap-1">
|
||||
<Layers size={12} />
|
||||
<span>{deck.cardsCount} cartes de mémoire</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Circular progress bar rendering */}
|
||||
<div className="relative w-12 h-12 flex items-center justify-center shrink-0">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle cx="24" cy="24" r="19" stroke="currentColor" strokeWidth="2" className="text-zinc-100 dark:text-zinc-800" fill="transparent" />
|
||||
<circle cx="24" cy="24" r="19" stroke="currentColor" strokeWidth="3" className="text-sage" fill="transparent" strokeDasharray={2 * Math.PI * 19} strokeDashoffset={2 * Math.PI * 19 * (1 - deck.masteryScore)} />
|
||||
</svg>
|
||||
<span className="absolute text-[10px] font-mono font-black text-sage">
|
||||
{Math.round(deck.masteryScore * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center text-[10.5px] font-medium text-concrete pt-1">
|
||||
{dueCount > 0 ? (
|
||||
<span className="bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/15 px-2.5 py-1 rounded-full flex items-center gap-1 font-bold animate-pulse">
|
||||
{dueCount} à réviser
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-sage/10 text-sage dark:text-sage border border-sage/15 px-2.5 py-1 rounded-full flex items-center gap-1 font-bold">
|
||||
À jour
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-slate-500/5 dark:bg-white/5 border border-border px-2.5 py-1 rounded-full flex items-center gap-1 font-mono">
|
||||
<Calendar size={10} />
|
||||
Prochain : {formattingDate(deck.nextReviewDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1 border-t border-border/40">
|
||||
<button
|
||||
onClick={() => onSelectNote(deck.noteId)}
|
||||
className="flex-1 h-9 flex items-center justify-center text-[10.5px] uppercase tracking-wider font-bold text-muted-ink hover:text-ink dark:text-dark-muted dark:hover:text-dark-ink border border-border rounded-lg bg-white/50 dark:bg-transparent hover:bg-slate-50 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Ouvrir note
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStartReview(deck.noteId)}
|
||||
className="flex-1 h-9 flex items-center justify-center bg-accent text-white text-[10.5px] uppercase tracking-wider font-bold rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1.5 shadow-sm shadow-accent/10"
|
||||
>
|
||||
<GraduationCap size={14} />
|
||||
Réviser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-center p-16 border border-dashed border-border/60 rounded-3xl bg-[#FAF9F6]/30 py-24">
|
||||
<div className="w-16 h-16 rounded-2xl bg-accent/5 text-accent flex items-center justify-center mb-6">
|
||||
<GraduationCap size={32} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-serif font-black text-ink dark:text-dark-ink mb-2">Aucun deck de flashcards</h3>
|
||||
<p className="text-sm text-concrete max-w-md font-light mb-8">
|
||||
Démarrez votre apprentissage en générant des flashcards à l'aide de l'IA directement depuis la barre d'outils de vos notes architecturales.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onSelectNote('n1')}
|
||||
className="h-11 px-6 bg-ink dark:bg-ochre text-paper dark:text-ink rounded-xl text-xs font-bold uppercase tracking-widest hover:opacity-90 transition-all flex items-center gap-2"
|
||||
>
|
||||
<BookOpen size={15} />
|
||||
Essayer sur la Note "Grid Systems"
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* SCREEN B: Active Deck session review state */}
|
||||
{isSessionActive && !isSessionFinished && (
|
||||
<motion.div
|
||||
key="active-session"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
className="w-full max-w-2xl flex flex-col items-center gap-10"
|
||||
>
|
||||
{/* Header navigation bar */}
|
||||
<div className="w-full flex items-center justify-between px-2 text-xs font-medium text-concrete">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={currentCardIndex === 0}
|
||||
className="flex items-center gap-1 hover:text-ink dark:hover:text-dark-ink disabled:opacity-30 transition-colors cursor-pointer"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
<span>Précédent</span>
|
||||
</button>
|
||||
|
||||
<div className="px-3.5 py-1.5 bg-slate-100 dark:bg-white/5 border border-border/40 text-[11px] font-mono tracking-widest font-bold rounded-full text-ink dark:text-dark-ink">
|
||||
{currentCardIndex + 1} / {sessionCards.length}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={currentCardIndex === sessionCards.length - 1}
|
||||
className="flex items-center gap-1 hover:text-ink dark:hover:text-dark-ink disabled:opacity-30 transition-colors cursor-pointer"
|
||||
>
|
||||
<span>Suivant</span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Centered Flashcard */}
|
||||
<div
|
||||
id="flashcard-container"
|
||||
onClick={handleCardFlip}
|
||||
className="w-[480px] h-[280px] cursor-pointer select-none perspective group"
|
||||
>
|
||||
<div className={`relative w-full h-full transition-transform duration-500 transform-style preserve-3d ${isFlipped ? 'rotate-y-180' : ''}`}>
|
||||
|
||||
{/* RECTO - Front */}
|
||||
<div className="absolute inset-0 w-full h-full backface-hidden bg-[#FAF9F5] dark:bg-slate-900 border border-border hover:border-accent/40 rounded-2xl p-8 flex flex-col justify-between shadow-md transition-colors">
|
||||
<div className="flex justify-between items-start text-[10px] font-mono text-concrete/75 uppercase tracking-widest">
|
||||
<span>Recto : Question</span>
|
||||
<span className="bg-slate-200/50 dark:bg-white/10 px-2 py-0.5 rounded text-[8.5px]">Cliquer pour tourner</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center p-2 text-center">
|
||||
<p className="text-xl font-serif font-black text-ink dark:text-dark-ink leading-relaxed">
|
||||
{sessionCards[currentCardIndex]?.question}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-center text-concrete italic font-light pt-2 shrink-0 border-t border-border/10">
|
||||
Raccourci : [Espace] pour révéler la réponse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VERSO - Back */}
|
||||
<div className="absolute inset-0 w-full h-full backface-hidden rotate-y-180 bg-white dark:bg-paper dark:text-ink border border-border rounded-2xl p-8 flex flex-col justify-between shadow-xl">
|
||||
<div className="flex justify-between items-start text-[10px] font-mono text-concrete/75 uppercase tracking-widest">
|
||||
<span className="text-accent font-bold">Verso : Réponse</span>
|
||||
<span className="bg-accent/10 px-2 py-0.5 rounded text-[8.5px] text-accent">Duo Mémoire</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center p-2 text-center overflow-y-auto max-h-[160px] custom-scrollbar">
|
||||
<p className="text-sm font-light text-ink leading-relaxed">
|
||||
{sessionCards[currentCardIndex]?.answer}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-center text-concrete/60 italic font-light pt-2 shrink-0 border-t border-border/15">
|
||||
Raccourcis : [1] Raté, [2] Hésitant, [3] Sûr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grading Buttons - Rendered after Verso is revealed */}
|
||||
<div className="h-16 flex items-center justify-center w-full">
|
||||
<AnimatePresence mode="wait">
|
||||
{isFlipped ? (
|
||||
<motion.div
|
||||
key="grading-expanded"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex items-center gap-4 w-full justify-center max-w-md"
|
||||
>
|
||||
<button
|
||||
id="grade-btn-fail"
|
||||
onClick={(e) => { e.stopPropagation(); handleEvaluate('fail'); }}
|
||||
className="flex-1 flex flex-col items-center justify-center h-14 rounded-2xl border border-rust/10 bg-rust/10 font-black text-rust cursor-pointer hover:bg-rust/15 transition-all text-xs"
|
||||
>
|
||||
<span className="text-sm">Raté</span>
|
||||
<span className="opacity-70 text-[9px] font-light font-mono mt-0.5">Touche 1</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="grade-btn-hesitant"
|
||||
onClick={(e) => { e.stopPropagation(); handleEvaluate('hesitant'); }}
|
||||
className="flex-1 flex flex-col items-center justify-center h-14 rounded-2xl border border-ochre/15 bg-ochre/10 font-black text-ochre cursor-pointer hover:bg-ochre/15 transition-all text-xs"
|
||||
>
|
||||
<span className="text-sm">Hésitant</span>
|
||||
<span className="opacity-70 text-[9px] font-light font-mono mt-0.5">Touche 2</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="grade-btn-sure"
|
||||
onClick={(e) => { e.stopPropagation(); handleEvaluate('sure'); }}
|
||||
className="flex-1 flex flex-col items-center justify-center h-14 rounded-2xl border border-sage/15 bg-sage/10 font-black text-sage cursor-pointer hover:bg-sage/15 transition-all text-xs"
|
||||
>
|
||||
<span className="text-sm">Sûr</span>
|
||||
<span className="opacity-70 text-[9px] font-light font-mono mt-0.5">Touche 3</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.button
|
||||
key="reveal-btn"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={handleCardFlip}
|
||||
className="h-12 px-8 bg-ink dark:bg-dark-ink text-paper dark:text-dark-paper text-xs uppercase font-bold tracking-widest rounded-xl hover:opacity-90 active:scale-98 transition-all shadow-md shrink-0 cursor-pointer"
|
||||
>
|
||||
Révéler la réponse (Espace)
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* SCREEN C: Finishing dashboard view with Donut Chart and actions */}
|
||||
{isSessionFinished && (
|
||||
<motion.div
|
||||
key="finished-stats"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
className="w-full max-w-lg flex flex-col items-center text-center gap-8"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="w-12 h-12 rounded-full bg-sage/10 text-sage flex items-center justify-center mx-auto mb-3">
|
||||
<Award size={26} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-serif font-black text-ink dark:text-dark-ink">Félicitations !</h1>
|
||||
<p className="text-sm font-light text-muted-ink dark:text-dark-muted">
|
||||
Vous venez de finir votre session de révision de la note active.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom SVG Donut Chart showing score */}
|
||||
<div className="relative w-44 h-44 flex items-center justify-center my-2">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle cx="88" cy="88" r="64" stroke="currentColor" strokeWidth="12" className="text-zinc-100 dark:text-zinc-900" fill="transparent" />
|
||||
<circle cx="88" cy="88" r="64" stroke="currentColor" strokeWidth="12" className="text-sage" fill="transparent" strokeDasharray={2 * Math.PI * 64} strokeDashoffset={2 * Math.PI * 64 * (1 - (finishedStats.percentage / 100))} strokeLinecap="round" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-4.5xl font-serif font-black text-ink dark:text-dark-ink leading-none">
|
||||
{finishedStats.percentage}%
|
||||
</span>
|
||||
<p className="text-[10px] uppercase font-bold tracking-widest text-concrete mt-1">Sûr de soi</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core Analytics parameters (Stats) */}
|
||||
<div className="grid grid-cols-3 gap-4 w-full border-t border-b border-border py-6 select-none bg-[#FCFCFA] dark:bg-white/[0.01] rounded-2xl px-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-serif font-bold text-ink dark:text-dark-ink">{sessionCards.length}</p>
|
||||
<p className="text-[10px] uppercase tracking-wide font-medium text-concrete">Révisées</p>
|
||||
</div>
|
||||
<div className="space-y-1 border-l border-r border-border/60">
|
||||
<p className="text-2xl font-serif font-bold text-rust">{finishedStats.failCount}</p>
|
||||
<p className="text-[10px] uppercase tracking-wide font-medium text-concrete">À revoir</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-serif font-bold text-sage">{finishedStats.sureCount}</p>
|
||||
<p className="text-[10px] uppercase tracking-wide font-medium text-concrete">Maîtrisées</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full">
|
||||
<button
|
||||
onClick={handleExitSession}
|
||||
className="flex-1 h-11 border border-border text-ink dark:text-dark-ink rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-slate-50 dark:hover:bg-white/5 transition-all cursor-pointer"
|
||||
>
|
||||
Retour aux decks
|
||||
</button>
|
||||
{finishedStats.failCount > 0 && (
|
||||
<button
|
||||
onClick={() => handleStartReview(activeDeckId!, true)}
|
||||
className="flex-1 h-11 bg-[#8F4C38] text-white rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-[#8F4C38]/95 transition-all flex items-center justify-center gap-1.5 shadow-sm cursor-pointer"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Ré-réviser les ratées
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
611
architectural-grid1/src/components/SearchModal.tsx
Normal file
611
architectural-grid1/src/components/SearchModal.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Bookmark,
|
||||
Layers,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
HelpCircle,
|
||||
X,
|
||||
CornerDownRight,
|
||||
Folder,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
Command,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import { Note, Carnet } from '../types';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
notes: Note[];
|
||||
carnets: Carnet[];
|
||||
onSelectNote: (noteId: string) => void;
|
||||
}
|
||||
|
||||
interface SearchMatch {
|
||||
id: string; // Unique match identifier
|
||||
noteId: string;
|
||||
noteTitle: string;
|
||||
path: string;
|
||||
type: 'document' | 'heading' | 'paragraph' | 'list';
|
||||
headingLevel?: number;
|
||||
text: string;
|
||||
matchedText: string;
|
||||
lineIndex: number;
|
||||
}
|
||||
|
||||
export const SearchModal: React.FC<SearchModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
notes,
|
||||
carnets,
|
||||
onSelectNote
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [useRegex, setUseRegex] = useState(false);
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [includeChildDocs, setIncludeChildDocs] = useState(true);
|
||||
const [searchInTrash, setSearchInTrash] = useState(false);
|
||||
const [savedQueries, setSavedQueries] = useState<string[]>(['block', 'siyuan', 'guide']);
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus input on launch
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle global keybindings in modal
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(prev + 1, filteredMatches.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filteredMatches[selectedIndex]) {
|
||||
const m = filteredMatches[selectedIndex];
|
||||
onSelectNote(m.noteId);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, selectedIndex]);
|
||||
|
||||
// Helper: reconstruct carnet path
|
||||
const getCarnetPath = (carnetId: string): string => {
|
||||
const segments: string[] = [];
|
||||
let current = carnets.find(c => c.id === carnetId);
|
||||
while (current) {
|
||||
segments.unshift(current.name);
|
||||
current = current.parentId ? carnets.find(c => c.id === current.parentId) : undefined;
|
||||
}
|
||||
return segments.join('/');
|
||||
};
|
||||
|
||||
// Safe term escape for RegExp
|
||||
const escapeRegExp = (string: string) => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
};
|
||||
|
||||
// Perform multi-match search logic across document titles and contents
|
||||
const filteredMatches = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const matches: SearchMatch[] = [];
|
||||
const searchRegex = (() => {
|
||||
try {
|
||||
const flag = caseSensitive ? '' : 'i';
|
||||
const pattern = useRegex ? query : escapeRegExp(query);
|
||||
return new RegExp(pattern, flag);
|
||||
} catch (e) {
|
||||
return null; // Handle partial regex input gracefully
|
||||
}
|
||||
})();
|
||||
|
||||
if (!searchRegex) return [];
|
||||
|
||||
// Filter notes depending on trash status
|
||||
const targetNotes = notes.filter(n => searchInTrash ? n.isDeleted : !n.isDeleted);
|
||||
|
||||
targetNotes.forEach(note => {
|
||||
const notePath = getCarnetPath(note.carnetId);
|
||||
const fullPath = notePath ? `${notePath}/${note.title}` : note.title;
|
||||
|
||||
// 1. Check Title match
|
||||
if (searchRegex.test(note.title)) {
|
||||
matches.push({
|
||||
id: `${note.id}-title`,
|
||||
noteId: note.id,
|
||||
noteTitle: note.title,
|
||||
path: fullPath,
|
||||
type: 'document',
|
||||
text: note.title,
|
||||
matchedText: note.title,
|
||||
lineIndex: -1
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Parse Content blocks / lines
|
||||
if (note.content) {
|
||||
const lines = note.content.split('\n');
|
||||
lines.forEach((line, index) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (searchRegex.test(trimmed)) {
|
||||
let type: 'heading' | 'paragraph' | 'list' = 'paragraph';
|
||||
let headingLevel = undefined;
|
||||
let displayVal = trimmed;
|
||||
|
||||
// Classify content structure elements
|
||||
if (trimmed.startsWith('#')) {
|
||||
type = 'heading';
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
headingLevel = headingMatch[1].length;
|
||||
displayVal = headingMatch[2];
|
||||
}
|
||||
} else if (/^[-*+]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) {
|
||||
type = 'list';
|
||||
displayVal = trimmed.replace(/^[-*+\d.]+\s+/, '');
|
||||
}
|
||||
|
||||
matches.push({
|
||||
id: `${note.id}-line-${index}`,
|
||||
noteId: note.id,
|
||||
noteTitle: note.title,
|
||||
path: fullPath,
|
||||
type,
|
||||
headingLevel,
|
||||
text: trimmed,
|
||||
matchedText: displayVal,
|
||||
lineIndex: index
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return matches;
|
||||
}, [notes, query, useRegex, caseSensitive, searchInTrash, carnets]);
|
||||
|
||||
// Ensure index remains in bounds when matches array updates
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [query]);
|
||||
|
||||
// Toggle saving criteria
|
||||
const handleSaveCriteria = () => {
|
||||
if (query.trim() && !savedQueries.includes(query.trim())) {
|
||||
setSavedQueries(prev => [...prev, query.trim()]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCriteria = () => {
|
||||
setSavedQueries(prev => prev.filter(q => q !== query.trim()));
|
||||
};
|
||||
|
||||
// Count distinct notes involved in match list
|
||||
const docMatchesCount = useMemo(() => {
|
||||
const uniqueNoteIds = new Set(filteredMatches.map(m => m.noteId));
|
||||
return uniqueNoteIds.size;
|
||||
}, [filteredMatches]);
|
||||
|
||||
const activeMatch = filteredMatches[selectedIndex];
|
||||
|
||||
// Dynamically load document content with visual query highlights
|
||||
const highlightedNotePreviewContent = useMemo(() => {
|
||||
if (!activeMatch) return null;
|
||||
const currentNote = notes.find(n => n.id === activeMatch.noteId);
|
||||
if (!currentNote) return null;
|
||||
|
||||
if (!query.trim()) return currentNote.content;
|
||||
|
||||
try {
|
||||
const flag = caseSensitive ? 'g' : 'gi';
|
||||
const searchPattern = useRegex ? query : escapeRegExp(query);
|
||||
const highlightRegex = new RegExp(`(${searchPattern})`, flag);
|
||||
|
||||
// Return content split by line to let us format block matches neatly
|
||||
const lines = (currentNote.content || '').split('\n');
|
||||
|
||||
// Let's frame the match around the matched line for contextual proximity
|
||||
const targetIndex = activeMatch.lineIndex >= 0 ? activeMatch.lineIndex : 0;
|
||||
const startLine = Math.max(0, targetIndex - 3);
|
||||
const endLine = Math.min(lines.length - 1, targetIndex + 5);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 my-2">
|
||||
{startLine > 0 && (
|
||||
<div className="text-[10px] text-concrete/40 italic pl-4">...</div>
|
||||
)}
|
||||
{lines.slice(startLine, endLine + 1).map((line, idx) => {
|
||||
const absoluteIdx = startLine + idx;
|
||||
const isMatchLine = absoluteIdx === targetIndex;
|
||||
const hasMatches = highlightRegex.test(line);
|
||||
|
||||
// Reconstruct highlighted segments
|
||||
const segments = line.split(highlightRegex);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={absoluteIdx}
|
||||
className={`py-1 px-3 rounded-lg text-xs leading-relaxed flex items-start gap-4 transition-colors
|
||||
${isMatchLine ? 'bg-amber-100/15 border-l-2 border-amber-500 pl-2.5 dark:bg-amber-500/5' : 'opacity-85'}`}
|
||||
>
|
||||
<span className="font-mono text-[9px] text-concrete/40 text-right w-6 select-none mt-1">
|
||||
{absoluteIdx + 1}
|
||||
</span>
|
||||
|
||||
<span className="font-sans text-ink dark:text-dark-ink break-all">
|
||||
{hasMatches ? (
|
||||
segments.map((seg, sIdx) => {
|
||||
const matchesPattern = highlightRegex.test(seg);
|
||||
return matchesPattern ? (
|
||||
<mark
|
||||
key={sIdx}
|
||||
className="bg-amber-500/30 text-ink dark:text-white dark:bg-amber-400/40 rounded px-0.5 border-b border-amber-600 font-semibold"
|
||||
>
|
||||
{seg}
|
||||
</mark>
|
||||
) : (
|
||||
seg
|
||||
);
|
||||
})
|
||||
) : (
|
||||
line
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{endLine < lines.length - 1 && (
|
||||
<div className="text-[10px] text-concrete/40 italic pl-4">...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} catch (e) {
|
||||
return <div className="text-xs text-concrete pr-4">{currentNote.content}</div>;
|
||||
}
|
||||
}, [activeMatch, notes, query, useRegex, caseSensitive]);
|
||||
|
||||
// Render text segment highlight in results row items
|
||||
const renderHighlightedRowText = (text: string) => {
|
||||
if (!query.trim()) return text;
|
||||
try {
|
||||
const flag = caseSensitive ? 'gi' : 'gi';
|
||||
const searchPattern = useRegex ? query : escapeRegExp(query);
|
||||
const highlightRegex = new RegExp(`(${searchPattern})`, flag);
|
||||
const segments = text.split(highlightRegex);
|
||||
|
||||
return (
|
||||
<span className="truncate">
|
||||
{segments.map((seg, sIdx) => {
|
||||
const isMatch = highlightRegex.test(seg);
|
||||
return isMatch ? (
|
||||
<mark key={sIdx} className="bg-amber-400/35 text-ink dark:text-white dark:bg-amber-500/45 px-0.5 rounded font-black">
|
||||
{seg}
|
||||
</mark>
|
||||
) : (
|
||||
seg
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-xs flex items-center justify-center z-[100] p-4 sm:p-6 select-none font-sans">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, y: 10 }}
|
||||
className="w-full max-w-[840px] h-[580px] sm:h-[640px] rounded-2xl bg-white dark:bg-[#121212] border border-border dark:border-zinc-800 shadow-2xl flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* TOP Advanced Search Bar Row */}
|
||||
<div className="p-4 border-b border-border/60 dark:border-zinc-800/80 bg-paper/50 dark:bg-[#161616] flex flex-col gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2.5 relative">
|
||||
<Search size={18} className="text-concrete absolute left-3 top-1/2 -translate-y-1/2 shrink-0" />
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Rechercher des documents ou des blocs de texte..."
|
||||
className="w-full text-sm pl-10 pr-24 py-2.5 rounded-xl border border-border/70 dark:border-zinc-800/80 bg-white/85 dark:bg-[#1C1C1C] text-ink dark:text-dark-ink placeholder-concrete/50 outline-none focus:border-accent"
|
||||
/>
|
||||
|
||||
{/* Config Quick Badges */}
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 bg-paper dark:bg-transparent rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setCaseSensitive(!caseSensitive)}
|
||||
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md hover:bg-black/5 dark:hover:bg-white/5 uppercase select-none transition-colors
|
||||
${caseSensitive ? 'text-accent bg-accent/5' : 'text-concrete'}`}
|
||||
title="Respecter la casse (Aa)"
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUseRegex(!useRegex)}
|
||||
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md hover:bg-black/5 dark:hover:bg-white/5 uppercase select-none transition-colors
|
||||
${useRegex ? 'text-accent bg-accent/5' : 'text-concrete'}`}
|
||||
title="Activer Regex (.*)"
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-md text-concrete transition-all"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick saved criteria filter tags */}
|
||||
{savedQueries.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-concrete font-bold tracking-tight">
|
||||
<span className="uppercase text-[9px]">Favoris:</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{savedQueries.map(sq => (
|
||||
<button
|
||||
key={sq}
|
||||
onClick={() => setQuery(sq)}
|
||||
className={`px-2 py-0.5 rounded-md border text-[9.5px] font-medium transition-all hover:border-accent
|
||||
${query === sq
|
||||
? 'bg-accent/10 border-accent text-accent'
|
||||
: 'bg-white dark:bg-zinc-800 border-border/40 text-muted-ink'}`}
|
||||
>
|
||||
{sq}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* UTILITY BAR Row -> Match statistics with action links */}
|
||||
<div className="px-4 py-2 bg-[#F8F7F4] dark:bg-[#141414] border-b border-border/40 dark:border-zinc-850 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Arrow Switchers */}
|
||||
<div className="flex items-center gap-1 border border-border/40 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-lg p-0.5">
|
||||
<button
|
||||
disabled={filteredMatches.length === 0}
|
||||
onClick={() => setSelectedIndex(prev => Math.max(0, prev - 1))}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-concrete disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<span className="text-[9.5px] font-bold font-mono px-1.5 text-concrete">
|
||||
{filteredMatches.length > 0 ? `${selectedIndex + 1}/${filteredMatches.length}` : '0/0'}
|
||||
</span>
|
||||
<button
|
||||
disabled={filteredMatches.length === 0}
|
||||
onClick={() => setSelectedIndex(prev => Math.min(filteredMatches.length - 1, prev + 1))}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-concrete disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-[11px] font-medium text-concrete">
|
||||
{filteredMatches.length > 0
|
||||
? `Trouvé ${filteredMatches.length} occurrences dans ${docMatchesCount} documents`
|
||||
: query.trim() ? "Aucun élément ne correspond" : "Saisissez votre requête"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Toolbar Action Links */}
|
||||
<div className="flex items-center gap-4">
|
||||
{query.trim() && (
|
||||
<button
|
||||
onClick={savedQueries.includes(query.trim()) ? handleRemoveCriteria : handleSaveCriteria}
|
||||
className="text-[10px] font-bold uppercase tracking-wider text-accent border-b border-dashed border-accent hover:border-solid select-none"
|
||||
>
|
||||
{savedQueries.includes(query.trim()) ? 'Supprimer favori' : 'Sauvegarder recherche'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-[10.5px] font-medium text-concrete">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeChildDocs}
|
||||
onChange={(e) => setIncludeChildDocs(e.target.checked)}
|
||||
className="rounded border-border/60 text-accent focus:ring-accent w-3 h-3"
|
||||
/>
|
||||
<span>Sous-docs inclus</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-[10.5px] font-medium text-concrete">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={searchInTrash}
|
||||
onChange={(e) => setSearchInTrash(e.target.checked)}
|
||||
className="rounded border-border/60 text-accent focus:ring-accent w-3 h-3"
|
||||
/>
|
||||
<span>Corbeille incluse</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DUAL SECTION LAYOUT */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
|
||||
{/* Left Section: Scrollable matches list */}
|
||||
<div className="w-[45%] h-full border-r border-border/40 dark:border-zinc-800 flex flex-col bg-[#FAF9F5]/30 dark:bg-[#121212]/30 overflow-hidden">
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
|
||||
{filteredMatches.map((m, idx) => {
|
||||
const isSelected = idx === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setSelectedIndex(idx)}
|
||||
onDoubleClick={() => {
|
||||
onSelectNote(m.noteId);
|
||||
onClose();
|
||||
}}
|
||||
className={`p-2.5 rounded-xl cursor-pointer text-left select-none relative group/item transition-all flex flex-col gap-1 border
|
||||
${isSelected
|
||||
? 'bg-white dark:bg-zinc-800 shadow-md border-amber-500/30'
|
||||
: 'border-transparent hover:bg-black/[0.02] dark:hover:bg-white/[0.02]/30'}`}
|
||||
>
|
||||
{/* Selection overlay accent */}
|
||||
{isSelected && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3.5 bg-amber-500 rounded-r-full" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{/* Element classifier badges */}
|
||||
{m.type === 'document' && (
|
||||
<FileText size={12} className="text-sky-500 shrink-0" />
|
||||
)}
|
||||
{m.type === 'heading' && (
|
||||
<span className="text-[8.5px] font-extrabold uppercase bg-indigo-50 dark:bg-indigo-950/40 text-indigo-500 border border-indigo-500/10 px-1 rounded-sm shrink-0 font-mono">
|
||||
H{m.headingLevel || ''}
|
||||
</span>
|
||||
)}
|
||||
{m.type === 'list' && (
|
||||
<span className="text-[8.5px] font-extrabold uppercase bg-emerald-50 dark:bg-emerald-950/40 text-emerald-500 border border-emerald-500/10 px-1 rounded-sm shrink-0 font-mono">
|
||||
LIST
|
||||
</span>
|
||||
)}
|
||||
{m.type === 'paragraph' && (
|
||||
<span className="text-[8px] font-extrabold uppercase bg-zinc-100 dark:bg-zinc-800 text-concrete border border-border/20 px-1 rounded-sm shrink-0 font-mono">
|
||||
TXT
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={`font-semibold truncate leading-none text-xs ${isSelected ? 'text-ink dark:text-dark-ink' : 'text-muted-ink'}`}>
|
||||
{m.noteTitle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Highlighted snippet row content */}
|
||||
<div className="text-[11px] text-concrete truncate pl-4.5 font-sans leading-tight">
|
||||
{renderHighlightedRowText(m.matchedText)}
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb row path */}
|
||||
<div className="text-[8.5px] font-mono tracking-widest uppercase text-concrete/45 truncate pl-4.5 mt-0.5 max-w-full">
|
||||
{m.path}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredMatches.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-6 text-concrete pt-32 space-y-2">
|
||||
<Search size={22} className="opacity-35 text-concrete animate-pulse" />
|
||||
<p className="text-[11px] font-medium italic opacity-70">
|
||||
{query.trim() ? "Aucun bloc ou doc ne correspond à cette recherche." : "Taper pour obtenir des résultats instantanés."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Section: Scrollable content preview card with visual highlighted markers */}
|
||||
<div className="flex-1 h-full bg-[#FCFCFA]/80 dark:bg-[#151515] flex flex-col overflow-hidden">
|
||||
{activeMatch ? (
|
||||
<div className="flex-1 flex flex-col p-5 overflow-hidden justify-between">
|
||||
<div className="space-y-4 overflow-hidden flex flex-col flex-1">
|
||||
{/* Breadcrumb locator line */}
|
||||
<div className="flex items-center gap-1.5 p-2 bg-black/[0.02] dark:bg-white/[0.02] border border-border/40 rounded-xl">
|
||||
<Folder size={11} className="text-concrete" />
|
||||
<span className="text-[9.5px] font-mono tracking-widest text-concrete font-medium uppercase truncate flex-1">
|
||||
{activeMatch.path}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Document focus heading title */}
|
||||
<div className="border-b border-border/40 dark:border-zinc-800 pb-2">
|
||||
<h4 className="text-[13px] font-serif font-black text-ink dark:text-dark-ink">
|
||||
{activeMatch.noteTitle}
|
||||
</h4>
|
||||
<p className="text-[8px] uppercase tracking-wider text-concrete font-bold mt-1">APERÇU CONTEXTUEL DU BLOC</p>
|
||||
</div>
|
||||
|
||||
{/* Dynamic document contents highlighted and framed */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar pr-1 bg-white dark:bg-[#121212] border border-border/30 rounded-xl p-3.5 shadow-inner">
|
||||
{highlightedNotePreviewContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions trigger buttons */}
|
||||
<div className="pt-4 border-t border-border/40 dark:border-zinc-800 flex items-center justify-between shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
onSelectNote(activeMatch.noteId);
|
||||
onClose();
|
||||
}}
|
||||
className="px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black hover:scale-102 active:scale-98 text-xs font-semibold rounded-xl flex items-center gap-2 transition-all shadow-sm"
|
||||
>
|
||||
<CornerDownRight size={13} />
|
||||
<span>Ouvrir dans l'éditeur</span>
|
||||
</button>
|
||||
<span className="text-[10px] text-concrete font-bold font-mono bg-paper dark:bg-white/5 border border-border/30 px-2 py-1 rounded">
|
||||
ID: {activeMatch.noteId.slice(0, 6)}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6 text-concrete space-y-3">
|
||||
<HelpCircle size={24} className="opacity-25" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11.5px] font-bold">Aperçu du document</p>
|
||||
<p className="text-[10px] italic opacity-60">Sélectionnez un résultat de recherche de la colonne et explorez immédiatement son contenu sémantique.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* BOTTOM Status Keyboard shortcuts hint footer bar */}
|
||||
<div className="p-3.5 bg-[#FAF9F5] dark:bg-[#0E0E0E] border-t border-border/50 dark:border-zinc-800/60 flex items-center justify-between shrink-0 font-sans">
|
||||
<div className="flex items-center gap-5 text-[9.5px] font-bold text-concrete/75 antialiased">
|
||||
<span className="flex items-center gap-1.5"><strong className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-light">↑↓</strong> naviguer</span>
|
||||
<span className="flex items-center gap-1.5"><strong className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-light">Entrée</strong> ouvrir</span>
|
||||
<span className="flex items-center gap-1.5"><strong className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-light">Double clic</strong> ouvrir</span>
|
||||
<span className="flex items-center gap-1.5"><strong className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-light">Échap</strong> fermer</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-wider text-concrete/60">
|
||||
<Command size={10} />
|
||||
<span>Momento Search OS v2.3</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
92
architectural-grid1/src/components/SettingsView.tsx
Normal file
92
architectural-grid1/src/components/SettingsView.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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';
|
||||
import { BillingTab } from './settings/BillingTab';
|
||||
import { ProfileTab } from './settings/ProfileTab';
|
||||
|
||||
interface SettingsViewProps {
|
||||
activeSettingsTab: SettingsTab;
|
||||
setActiveSettingsTab: (tab: SettingsTab) => void;
|
||||
accentColor: string;
|
||||
onAccentColorChange: (color: string) => void;
|
||||
onLogout: () => void;
|
||||
onOpenSidebar?: () => void;
|
||||
}
|
||||
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
activeSettingsTab,
|
||||
setActiveSettingsTab,
|
||||
accentColor,
|
||||
onAccentColorChange,
|
||||
onLogout,
|
||||
onOpenSidebar
|
||||
}) => {
|
||||
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}
|
||||
onOpenSidebar={onOpenSidebar}
|
||||
/>
|
||||
|
||||
<div className="flex-1 px-6 sm: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 === 'billing' && (
|
||||
<BillingTab key="billing" />
|
||||
)}
|
||||
|
||||
{activeSettingsTab === 'appearance' && (
|
||||
<AppearanceTab
|
||||
key="appearance"
|
||||
accentColor={accentColor}
|
||||
onAccentColorChange={onAccentColorChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSettingsTab === 'profile' && (
|
||||
<ProfileTab
|
||||
key="profile"
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)}
|
||||
|
||||
{['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>
|
||||
);
|
||||
};
|
||||
875
architectural-grid1/src/components/Sidebar.tsx
Normal file
875
architectural-grid1/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,875 @@
|
||||
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,
|
||||
Home,
|
||||
Sparkles,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Search,
|
||||
BookMarked,
|
||||
User,
|
||||
ExternalLink,
|
||||
ChevronUp,
|
||||
HelpCircle,
|
||||
EyeOff,
|
||||
Layers,
|
||||
Scissors,
|
||||
Chrome,
|
||||
Crown,
|
||||
ArrowRight,
|
||||
GraduationCap
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { NavigationView, Carnet, Note, SettingsTab, Flashcard } 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-6 pr-3 py-1.5 text-[11px] transition-all rounded-lg text-left
|
||||
${isActive ? 'bg-white shadow-sm border border-border/50 dark:bg-white/10 text-ink font-semibold' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 truncate">
|
||||
<FileText size={12} className={isActive ? "text-accent shrink-0" : "text-concrete opacity-70 shrink-0"} />
|
||||
<span className="truncate">{note.title || "Note sans titre"}</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 || notes.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div
|
||||
className="flex items-center group relative h-8 select-none"
|
||||
style={{ paddingLeft: `${level * 10}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-[4px] top-[-10px] bottom-1/2 w-px bg-border/20" />
|
||||
)}
|
||||
{level > 0 && (
|
||||
<div className="absolute left-[4px] top-1/2 w-[8px] h-px bg-border/20" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center gap-1.5">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand();
|
||||
}}
|
||||
className="p-0.5 hover:bg-ink/5 dark:hover:bg-white/5 rounded transition-colors text-concrete"
|
||||
>
|
||||
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.15 }}>
|
||||
<ChevronRight size={12} />
|
||||
</motion.div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4" /> // Spacer for alignment
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
whileHover={{ x: 1 }}
|
||||
className={`flex-1 flex items-center gap-2 px-2 py-1 rounded-lg transition-all duration-200 group/item cursor-pointer relative
|
||||
${isActive ? 'bg-white shadow-sm border border-border/50 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-accent/5', 'ring-1', 'ring-accent/20');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('bg-accent/5', 'ring-1', 'ring-accent/20');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('bg-accent/5', 'ring-1', 'ring-accent/20');
|
||||
const draggedId = e.dataTransfer.getData('carnetId');
|
||||
if (draggedId && draggedId !== carnet.id) {
|
||||
onMove?.(draggedId, carnet.id);
|
||||
}
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
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);
|
||||
}}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-indicator"
|
||||
className="absolute -left-1 w-1 h-3.5 bg-accent rounded-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="w-5 h-5 flex items-center justify-center text-concrete shrink-0">
|
||||
{isExpanded ? (
|
||||
<FolderOpen size={13} className={isActive ? "text-accent" : "text-concrete opacity-80"} />
|
||||
) : (
|
||||
<Folder size={13} className={isActive ? "text-accent" : "text-concrete opacity-80"} />
|
||||
)}
|
||||
</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 font-semibold' : '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-0.5 hover:bg-ink/10 dark:hover:bg-white/10 rounded transition-all text-concrete hover:text-ink"
|
||||
title="Ajouter un sous-carnet"
|
||||
>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRename();
|
||||
}}
|
||||
className="p-0.5 hover:bg-ink/10 dark:hover:bg-white/10 rounded transition-all text-concrete hover:text-ink"
|
||||
title="Renommer"
|
||||
>
|
||||
<Edit3 size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="p-0.5 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-all text-concrete hover:text-red-500"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
|
||||
{notes.length > 0 && (
|
||||
<span className="text-[9px] font-bold text-concrete/40 px-1 border border-border/40 rounded-full group-hover:text-concrete transition-colors">
|
||||
{notes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative" style={{ marginLeft: `${(level + 1) * 10 - 2}px` }}>
|
||||
{/* Vertical line for nested content path */}
|
||||
<div className="absolute left-[2px] top-0 bottom-3 w-px bg-black/[0.06] dark:bg-white/[0.06]" />
|
||||
|
||||
<div className="space-y-0.5 py-0.5 pl-2.5">
|
||||
{children}
|
||||
{notes.map(note => (
|
||||
<NoteLink
|
||||
key={note.id}
|
||||
note={note}
|
||||
isActive={activeNoteId === note.id}
|
||||
onClick={() => onNoteClick(note.id)}
|
||||
/>
|
||||
))}
|
||||
{notes.length === 0 && !React.Children.count(children) && (
|
||||
<p className="pl-6 py-1 text-[9px] italic text-concrete/40 font-light">
|
||||
Vide
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
activeView: NavigationView;
|
||||
isSidebarOpen: boolean;
|
||||
setIsSidebarOpen: (val: boolean) => void;
|
||||
isDarkMode: boolean;
|
||||
setIsDarkMode: (val: boolean) => void;
|
||||
setActiveView: (view: NavigationView) => void;
|
||||
setActiveSettingsTab?: (tab: SettingsTab) => 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;
|
||||
onGoHome: () => void;
|
||||
onLogout: () => void;
|
||||
flashcards?: Flashcard[];
|
||||
onSelectReviewDeck?: (noteId: string) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
activeView,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
isDarkMode,
|
||||
setIsDarkMode,
|
||||
setActiveView,
|
||||
setActiveSettingsTab,
|
||||
carnets,
|
||||
notes,
|
||||
activeCarnetId,
|
||||
activeNoteId,
|
||||
setActiveCarnetId,
|
||||
setActiveNoteId,
|
||||
setShowNewCarnetModal,
|
||||
onDeleteCarnet,
|
||||
onMoveCarnet,
|
||||
onGoHome,
|
||||
onLogout,
|
||||
flashcards,
|
||||
onSelectReviewDeck
|
||||
}) => {
|
||||
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set(['4', '1', '2', '3'])); // Default expand key guides
|
||||
const [collapsedSections, setCollapsedSections] = React.useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
const newSet = new Set(collapsedSections);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
setCollapsedSections(newSet);
|
||||
};
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
const newSet = new Set(expandedIds);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
setExpandedIds(newSet);
|
||||
};
|
||||
|
||||
// Safe filtration based on searches
|
||||
const filteredCarnets = React.useMemo(() => {
|
||||
if (!searchQuery) return carnets;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return carnets.filter(c =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
notes.some(n => n.carnetId === c.id && n.title.toLowerCase().includes(q))
|
||||
);
|
||||
}, [carnets, searchQuery, notes]);
|
||||
|
||||
const activeNote = React.useMemo(() => {
|
||||
if (!activeNoteId) return null;
|
||||
return notes.find(n => n.id === activeNoteId);
|
||||
}, [notes, activeNoteId]);
|
||||
|
||||
// Extract outline markdown headings dynamically for the currently active note
|
||||
const headings = React.useMemo(() => {
|
||||
if (!activeNote || !activeNote.content) return [];
|
||||
const lines = activeNote.content.split('\n');
|
||||
const hs: { text: string; level: number }[] = [];
|
||||
lines.forEach(line => {
|
||||
const trimmed = line.trim();
|
||||
const hMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (hMatch) {
|
||||
hs.push({
|
||||
level: hMatch[1].length,
|
||||
text: hMatch[2].replace(/\[.*?\]\(.*?\)/g, '').replace(/[*_`]/g, '').trim()
|
||||
});
|
||||
}
|
||||
});
|
||||
return hs.slice(0, 10);
|
||||
}, [activeNote]);
|
||||
|
||||
const renderCarnetTree = (parentId: string | undefined = undefined, level: number = 0) => {
|
||||
return filteredCarnets
|
||||
.filter(c => c.parentId === parentId && !c.isDeleted)
|
||||
.map(carnet => {
|
||||
const carnetNotes = notes.filter(n => n.carnetId === carnet.id && !n.isDeleted);
|
||||
return (
|
||||
<SidebarItem
|
||||
key={carnet.id}
|
||||
carnet={carnet}
|
||||
isActive={activeCarnetId === carnet.id}
|
||||
notes={carnetNotes}
|
||||
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 (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isSidebarOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[60] lg:hidden"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<aside className={`
|
||||
fixed inset-y-0 left-0 lg:relative z-[70] lg:z-20
|
||||
w-80 h-screen bg-white dark:bg-[#0D0D0D] border-r border-border/85 flex shrink-0
|
||||
transition-all duration-300 ease-in-out font-sans overflow-hidden
|
||||
${isSidebarOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
|
||||
{/* Column 1: Ultra Narrow Left Utility Active-Rail Bar -> Identical to Ribbon in SiYuan */}
|
||||
<div className="w-[54px] border-r border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-5 shrink-0 select-none">
|
||||
{/* Top Stack: Logo & View Shortcuts */}
|
||||
<div className="flex flex-col items-center gap-4.5 w-full">
|
||||
{/* Visual SiYuan branding card */}
|
||||
<div
|
||||
onClick={() => { onGoHome(); setIsSidebarOpen(false); }}
|
||||
className="w-9 h-9 bg-accent hover:rotate-6 active:scale-95 flex items-center justify-center rounded-xl shadow-md transition-all cursor-pointer mb-2"
|
||||
title="Aller à la page d'accueil"
|
||||
>
|
||||
<span className="text-white font-serif font-black text-xs tracking-tight">M</span>
|
||||
</div>
|
||||
|
||||
{/* Tab items list */}
|
||||
<div className="flex flex-col gap-2 w-full px-1.5">
|
||||
{[
|
||||
{ id: 'notebooks', label: 'Feuilles / Docs', icon: <BookOpen size={16} /> },
|
||||
{ id: 'graph', label: 'Knowledge Map', icon: <Network size={16} /> },
|
||||
{ id: 'revision', label: 'Révisions / Decks', icon: <GraduationCap size={16} /> },
|
||||
{ id: 'agents', label: 'Agents IA Lab', icon: <Bot size={16} /> },
|
||||
{ id: 'reminders', label: 'Rappels & Alertes', icon: <Bell size={16} /> },
|
||||
].map(item => {
|
||||
const isSel = activeView === item.id || (item.id === 'agents' && ['brainstorm', 'insights', 'temporal'].includes(activeView));
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveView(item.id as any);
|
||||
}}
|
||||
className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group
|
||||
${isSel
|
||||
? 'bg-accent/10 text-accent border border-accent/25'
|
||||
: 'text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-black/[0.03] dark:hover:bg-white/[0.03]'}`}
|
||||
>
|
||||
{/* Visual status pin */}
|
||||
{isSel && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-4 bg-accent rounded-r-full" />
|
||||
)}
|
||||
{item.icon}
|
||||
|
||||
{/* Tooltip */}
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Stack: Trash, Light Mode, Settings, Logout */}
|
||||
<div className="flex flex-col gap-2 w-full px-1.5 items-center">
|
||||
{/* TRASH DISCIPLINE: Promoted directly on the sidebar utility ribbon for quick accessible storage management */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveView('trash');
|
||||
}}
|
||||
className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group
|
||||
${activeView === 'trash'
|
||||
? 'bg-rose-500/10 text-rose-500 border border-rose-500/25'
|
||||
: 'text-concrete hover:text-rose-500 hover:bg-rose-500/5'}`}
|
||||
>
|
||||
{activeView === 'trash' && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-4 bg-rose-500 rounded-r-full" />
|
||||
)}
|
||||
<Trash2 size={16} />
|
||||
{notes.some(n => n.isDeleted) && (
|
||||
<span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 bg-rose-500 rounded-full border border-[#FAF9F5] dark:border-[#0E0E0E]" />
|
||||
)}
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
Corbeille / Corbeille vide
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Shared */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveView('shared');
|
||||
}}
|
||||
className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group
|
||||
${activeView === 'shared'
|
||||
? 'bg-[#E3EBFB] text-sky-600 dark:bg-white/10 dark:text-sky-400'
|
||||
: 'text-concrete hover:text-sky-500 hover:bg-sky-500/5'}`}
|
||||
>
|
||||
{activeView === 'shared' && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-4 bg-sky-500 rounded-r-full" />
|
||||
)}
|
||||
<Users size={16} />
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
Partagé
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Web Clipper Simulator Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('toggle-clipper-simulator'));
|
||||
}}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-cyan-500 hover:bg-cyan-500/5 transition-all relative group"
|
||||
>
|
||||
<Scissors size={15} className="-rotate-90 text-cyan-500" />
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-[#0E0E0E] text-white text-[9.5px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-lg uppercase tracking-wider font-sans">
|
||||
Clipper Simulé
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Appearance Theme Switcher */}
|
||||
<button
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-black/[0.03] dark:hover:bg-white/[0.03] transition-all relative group"
|
||||
>
|
||||
{isDarkMode ? <Sun size={15} /> : <Moon size={15} />}
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{isDarkMode ? "Mode clair" : "Mode sombre"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveView('settings');
|
||||
setActiveSettingsTab?.('general');
|
||||
}}
|
||||
className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group
|
||||
${activeView === 'settings'
|
||||
? 'bg-accent/10 text-accent border border-accent/25'
|
||||
: 'text-concrete hover:text-ink dark:text-concrete hover:bg-black/[0.03] dark:hover:bg-white/[0.03]'}`}
|
||||
>
|
||||
<Settings size={15} />
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
Paramètres
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Logout button */}
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-red-500 hover:bg-rose-500/5 transition-all relative group"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
<span className="absolute left-[58px] top-1/2 -translate-y-1/2 bg-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
Déconnexion
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Large details zone (266px width) for list details - Dynamic depending on Ribbon view */}
|
||||
<div className="flex-1 h-full bg-[#FCFCFA] dark:bg-[#111111] flex flex-col overflow-hidden">
|
||||
|
||||
{/* Render notebook list detail content */}
|
||||
{activeView === 'notebooks' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-hidden h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookMarked size={14} className="text-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Documents</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewCarnetModal(true)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-all text-concrete hover:text-ink"
|
||||
title="Nouveau carnet parent"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Simple search bar as seen in standard file trees */}
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher doc..."
|
||||
className="w-full text-[11px] pl-7 pr-3 py-1.5 rounded-lg border border-border/60 bg-white/70 dark:bg-zinc-800 placeholder-concrete/50 outline-none focus:border-accent transition-colors text-ink dark:text-dark-ink"
|
||||
/>
|
||||
<Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-concrete opacity-60" />
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[9px] uppercase font-bold text-concrete hover:text-ink"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hierarchical list of documents */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar pr-1 mb-4">
|
||||
<div className="space-y-0.5">
|
||||
{renderCarnetTree()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IA Usage & Upgrade Section */}
|
||||
<div className="border-t border-[#E3EBFB]/60 dark:border-border/40 pt-4 mt-auto select-none">
|
||||
<div className="p-3 bg-slate-50/70 dark:bg-zinc-900 border border-border/40 rounded-xl space-y-2.5 shadow-[0_1px_2px_rgba(0,0,0,0.02)]">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="font-bold text-ink/75 dark:text-dark-ink/80 flex items-center gap-1">
|
||||
<Sparkles size={11} className="text-accent" />
|
||||
Utilisation de l'IA
|
||||
</span>
|
||||
<span className="font-semibold text-concrete">49 / 50 restants</span>
|
||||
</div>
|
||||
|
||||
<div className="h-1 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent hover:opacity-90 transition-all rounded-full" style={{ width: '98%' }} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveView('settings');
|
||||
setActiveSettingsTab?.('billing');
|
||||
}}
|
||||
className="w-full h-[28px] mt-1 flex items-center justify-between px-2.5 bg-accent/5 hover:bg-accent/10 hover:text-accent border border-accent/10 hover:border-accent/20 rounded-lg text-[10px] font-bold text-accent transition-all group cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Crown size={10} className="fill-accent/10" />
|
||||
Passer au Plan Pro
|
||||
</span>
|
||||
<ArrowRight size={10} className="group-hover:translate-x-0.5 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render intelligence modules */}
|
||||
{activeView === 'agents' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-y-auto custom-scrollbar justify-between">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles size={14} className="text-ochre" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Intelligence OS</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ id: 'brainstorm', label: 'Brainstorm Wave', desc: 'Génération d ideas rhizomatique', icon: <Wind size={15} /> },
|
||||
{ id: 'insights', label: 'Réseau Sémantique', desc: 'Cartographie de clusters DBSCAN', icon: <Network size={15} /> },
|
||||
{ id: 'temporal', label: 'Temporal Forecast', desc: 'Chronologie et prévisions', icon: <Clock size={15} /> },
|
||||
].map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
onClick={() => setActiveView(sub.id as any)}
|
||||
className="w-full text-left p-3 rounded-xl border border-border/30 hover:border-accent/30 bg-white dark:bg-zinc-800/50 hover:shadow-xs transition-all flex items-start gap-3 group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-black/[0.03] dark:bg-white/[0.03] text-concrete group-hover:text-accent group-hover:bg-accent/5 transition-all shrink-0">
|
||||
{sub.icon}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<p className="text-[12px] font-bold text-ink dark:text-dark-ink group-hover:text-accent transition-colors">{sub.label}</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">{sub.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pack quota discovery */}
|
||||
<div className="p-3.5 bg-white dark:bg-zinc-800 border border-border/40 rounded-xl space-y-2 mt-auto">
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="font-bold text-ink/70">Pack Découverte IA</span>
|
||||
<span className="font-semibold text-concrete">49 restants</span>
|
||||
</div>
|
||||
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent" style={{ width: '49%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reminders section list view */}
|
||||
{activeView === 'reminders' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center gap-1.5 mb-6">
|
||||
<Clock size={14} className="text-indigo-500" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Rappels Actifs</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-4 border border-dashed border-border/50 rounded-2xl bg-paper/20">
|
||||
<Bell size={20} className="text-concrete/40 mb-2.5" />
|
||||
<p className="text-[11px] text-concrete italic">Aucun rappel pour le moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flashcards / Révisions panel view inside Column 2 of Sidebar */}
|
||||
{activeView === 'revision' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center gap-1.5 mb-6">
|
||||
<GraduationCap size={14} className="text-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Decks Révision</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
const deckNotesList: { noteId: string; title: string; count: number; mastery: number }[] = [];
|
||||
const cardGroups: Record<string, Flashcard[]> = {};
|
||||
(flashcards || []).forEach(card => {
|
||||
if (!cardGroups[card.noteId]) cardGroups[card.noteId] = [];
|
||||
cardGroups[card.noteId].push(card);
|
||||
});
|
||||
|
||||
Object.keys(cardGroups).forEach(noteId => {
|
||||
const noteItem = notes.find(n => n.id === noteId);
|
||||
if (!noteItem || noteItem.isDeleted) return;
|
||||
const cList = cardGroups[noteId];
|
||||
const mastered = cList.filter(c => c.mastered).length;
|
||||
deckNotesList.push({
|
||||
noteId,
|
||||
title: noteItem.title || 'Note sans titre',
|
||||
count: cList.length,
|
||||
mastery: cList.length > 0 ? mastered / cList.length : 0
|
||||
});
|
||||
});
|
||||
|
||||
return deckNotesList.map(deck => (
|
||||
<button
|
||||
key={deck.noteId}
|
||||
onClick={() => {
|
||||
onSelectReviewDeck?.(deck.noteId);
|
||||
}}
|
||||
className="w-full text-left p-2.5 rounded-xl border border-border/40 hover:border-accent/30 bg-white dark:bg-zinc-800/40 hover:shadow-2xs transition-all flex items-start gap-2.5 group cursor-pointer"
|
||||
>
|
||||
<div className="w-7 h-7 bg-accent/5 text-accent rounded-lg flex items-center justify-center shrink-0 group-hover:bg-accent group-hover:text-white transition-all">
|
||||
<GraduationCap size={13} />
|
||||
</div>
|
||||
<div className="truncate flex-1 min-w-0">
|
||||
<p className="text-[11px] font-bold text-ink dark:text-dark-ink truncate group-hover:text-accent transition-colors">
|
||||
{deck.title}
|
||||
</p>
|
||||
<p className="text-[8.5px] text-concrete truncate mt-0.5">
|
||||
{deck.count} cartes · {Math.round(deck.mastery * 100)}% acquis
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
));
|
||||
})()}
|
||||
|
||||
{(!flashcards || flashcards.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-center p-4 border border-dashed border-border/55 rounded-2xl bg-paper/20">
|
||||
<GraduationCap size={18} className="text-concrete/40 mb-2" />
|
||||
<p className="text-[10px] text-concrete italic">Aucun deck créé.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shared panel view */}
|
||||
{activeView === 'shared' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center gap-1.5 mb-6">
|
||||
<Users size={14} className="text-sky-500" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Partagé avec moi</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-4 border border-dashed border-border/50 rounded-2xl bg-paper/20">
|
||||
<Users size={20} className="text-concrete/40 mb-2.5" />
|
||||
<p className="text-[11px] text-concrete italic">Aucun document partagé pour le moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trash bin panel view */}
|
||||
{activeView === 'trash' && (
|
||||
<div className="flex-1 flex flex-col p-4 h-full overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 mb-4">
|
||||
<Trash2 size={14} className="text-rose-500" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Fichiers Supprimés</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar pr-1 space-y-2 mb-4">
|
||||
{notes.filter(n => n.isDeleted).map(note => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="p-2.5 rounded-lg border border-border/40 bg-white dark:bg-zinc-800/40 text-left flex items-center justify-between gap-3 group"
|
||||
>
|
||||
<div className="truncate flex-1">
|
||||
<p className="text-[11px] font-bold text-ink dark:text-dark-ink truncate">{note.title || "Note sans titre"}</p>
|
||||
<p className="text-[8.5px] text-concrete">Supprimé le {new Date(note.deletedAt || note.date).toLocaleDateString('fr-FR')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveCarnetId(note.carnetId);
|
||||
setActiveNoteId(note.id);
|
||||
}}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-[9px] font-bold uppercase tracking-wider text-accent shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
Inspecter
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{notes.filter(n => n.isDeleted).length === 0 && (
|
||||
<div className="flex-1 h-32 flex flex-col items-center justify-center text-center p-4 border border-dashed border-border/55 bg-black/[0.01] rounded-2xl">
|
||||
<Trash2 size={16} className="text-concrete/30 mb-2" />
|
||||
<p className="text-[10px] text-concrete italic">Corbeille vide</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings panel category switcher list */}
|
||||
{activeView === 'settings' && (
|
||||
<div className="flex-1 flex flex-col p-4 overflow-y-auto custom-scrollbar">
|
||||
<div className="flex items-center gap-1.5 mb-6">
|
||||
<Settings size={14} className="text-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">Paramètres</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ id: 'general', label: 'Général', icon: <Archive size={12} /> },
|
||||
{ id: 'ai', label: 'Intelligence IA', icon: <Bot size={12} /> },
|
||||
{ id: 'billing', label: 'Tarifs & Abonnements', icon: <Sparkles size={12} /> },
|
||||
{ id: 'appearance', label: 'Thème & Stylisme', icon: <Sun size={12} /> },
|
||||
{ id: 'profile', label: 'Profil Utilisateur', icon: <User size={12} /> },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveSettingsTab?.(tab.id as any)}
|
||||
className="w-full text-left px-3 py-2 text-[11px] transition-all rounded-lg flex items-center gap-2.5 text-muted-ink hover:text-ink hover:bg-black/5"
|
||||
>
|
||||
<span className="text-concrete">{tab.icon}</span>
|
||||
<span className="font-semibold">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
67
architectural-grid1/src/components/SlashMenu.tsx
Normal file
67
architectural-grid1/src/components/SlashMenu.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
List,
|
||||
Quote,
|
||||
Code,
|
||||
Image as ImageIcon,
|
||||
Type,
|
||||
Sparkles,
|
||||
Link2
|
||||
} 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: 'embed', label: 'Living Block', icon: <Link2 size={14} />, desc: 'Insérer un bloc connecté dynamique', special: true },
|
||||
{ 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-accent/10 text-accent border-accent/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-grid1/src/components/TemporalView.tsx
Normal file
169
architectural-grid1/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>
|
||||
);
|
||||
};
|
||||
229
architectural-grid1/src/components/TrashView.tsx
Normal file
229
architectural-grid1/src/components/TrashView.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
X,
|
||||
FileText,
|
||||
Folder,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Menu
|
||||
} 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;
|
||||
onOpenSidebar?: () => void;
|
||||
}
|
||||
|
||||
export const TrashView: React.FC<TrashViewProps> = ({
|
||||
deletedNotes,
|
||||
deletedCarnets,
|
||||
onRestoreNote,
|
||||
onRestoreCarnet,
|
||||
onPermanentDeleteNote,
|
||||
onPermanentDeleteCarnet,
|
||||
onEmptyTrash,
|
||||
onOpenSidebar
|
||||
}) => {
|
||||
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-6 sm: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 gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onOpenSidebar}
|
||||
className="lg:hidden p-2 -ml-2 text-ink hover:bg-black/5 rounded-lg transition-colors"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl sm:text-4xl font-serif font-medium text-ink flex items-center gap-4">
|
||||
Corbeille <Trash2 size={24} 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>
|
||||
</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-accent/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-accent'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className={`p-3 rounded-2xl ${item.itemType === 'note' ? 'bg-accent/10 text-accent' : '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-accent/20 text-accent bg-accent/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-grid1/src/components/settings/AITab.tsx
Normal file
152
architectural-grid1/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-accent/5 transition-all duration-300">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-accent/10 rounded-2xl text-accent group-hover:bg-accent group-hover:text-white group-hover:scale-110 transition-all duration-300 border border-accent/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-accent"></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-accent">
|
||||
<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-accent flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-accent" />
|
||||
</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-accent flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-accent" />
|
||||
</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-accent">
|
||||
<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-accent flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-accent" />
|
||||
</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-accent flex items-center justify-center p-0.5 transition-all">
|
||||
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-accent" />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
164
architectural-grid1/src/components/settings/AppearanceTab.tsx
Normal file
164
architectural-grid1/src/components/settings/AppearanceTab.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
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>
|
||||
);
|
||||
|
||||
interface AppearanceTabProps {
|
||||
accentColor: string;
|
||||
onAccentColorChange: (color: string) => void;
|
||||
}
|
||||
|
||||
const PRESET_COLORS = [
|
||||
{ name: 'Ochre Swiss', value: '#A47148' },
|
||||
{ name: 'Alpine Moss', value: '#4E594A' },
|
||||
{ name: 'Terracotta', value: '#B1523E' },
|
||||
{ name: 'Slate Steel', value: '#4A5568' },
|
||||
{ name: 'Midnight', value: '#1E293B' },
|
||||
{ name: 'Sage Leaf', value: '#7C8363' },
|
||||
{ name: 'Bordeaux', value: '#722F37' },
|
||||
{ name: 'Carbon', value: '#262626' },
|
||||
];
|
||||
|
||||
export const AppearanceTab: React.FC<AppearanceTabProps> = ({ accentColor, onAccentColorChange }) => {
|
||||
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">
|
||||
{/* Accent Color Section */}
|
||||
<div className="md:col-span-2 bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-10 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-accent/5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-accent border border-border group-hover:scale-110 transition-transform duration-300 shadow-sm">
|
||||
<Palette size={20} />
|
||||
</div>
|
||||
<div className="space-y-0.5 text-left">
|
||||
<h4 className="text-base font-bold text-ink">Couleur d'accentuation</h4>
|
||||
<p className="text-[11px] text-concrete leading-tight">Définissez la couleur principale de votre espace de travail</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 bg-slate-50 dark:bg-black/20 px-4 py-2 rounded-xl border border-border/40">
|
||||
<div className="w-4 h-4 rounded-full border border-border/60" style={{ backgroundColor: accentColor }} />
|
||||
<span className="text-xs font-mono font-medium text-concrete uppercase tracking-widest">{accentColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
onClick={() => onAccentColorChange(color.value)}
|
||||
className={`relative w-12 h-12 rounded-2xl transition-all duration-300 hover:scale-110 flex items-center justify-center p-1 border-2 ${
|
||||
accentColor.toLowerCase() === color.value.toLowerCase()
|
||||
? 'border-accent shadow-lg shadow-accent/20'
|
||||
: 'border-transparent hover:border-concrete/20'
|
||||
}`}
|
||||
title={color.name}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full rounded-xl shadow-inner"
|
||||
style={{ backgroundColor: color.value }}
|
||||
/>
|
||||
{accentColor.toLowerCase() === color.value.toLowerCase() && (
|
||||
<motion.div
|
||||
layoutId="color-check"
|
||||
className="absolute inset-0 flex items-center justify-center text-white mix-blend-difference"
|
||||
>
|
||||
<Palette size={14} />
|
||||
</motion.div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="h-12 w-px bg-border/40 mx-2" />
|
||||
|
||||
<div className="relative group/custom">
|
||||
<input
|
||||
type="color"
|
||||
value={accentColor}
|
||||
onChange={(e) => onAccentColorChange(e.target.value)}
|
||||
className="w-12 h-12 rounded-2xl cursor-pointer opacity-0 absolute inset-0 z-10"
|
||||
/>
|
||||
<div className="w-12 h-12 rounded-2xl border-2 border-dashed border-concrete/30 flex items-center justify-center text-concrete transition-all group-hover/custom:border-accent group-hover/custom:text-accent">
|
||||
<Maximize size={16} />
|
||||
</div>
|
||||
<p className="absolute -bottom-6 left-1/2 -translate-x-1/2 text-[8px] font-bold uppercase tracking-widest opacity-0 group-hover/custom:opacity-40 whitespace-nowrap transition-opacity">Personnaliser</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
203
architectural-grid1/src/components/settings/BillingTab.tsx
Normal file
203
architectural-grid1/src/components/settings/BillingTab.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Check, Shield, Zap, Crown, CreditCard, ArrowRight, Activity, Clock } from 'lucide-react';
|
||||
|
||||
export const BillingTab: React.FC = () => {
|
||||
const plans = [
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Plan Basic',
|
||||
price: 'Gratuit',
|
||||
period: '',
|
||||
description: 'Pour découvrir la magie de Momento.',
|
||||
features: [
|
||||
'100 Notes max',
|
||||
'3 Carnets',
|
||||
'50 crédits IA (Lifetime)',
|
||||
'Recherche sémantique',
|
||||
'Historique 7 jours'
|
||||
],
|
||||
current: true,
|
||||
buttonText: 'Plan Actuel',
|
||||
buttonClass: 'bg-paper text-concrete cursor-default'
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Plan Pro',
|
||||
price: '9,90€',
|
||||
period: '/mois',
|
||||
description: 'Pour les consultants et créateurs exigeants.',
|
||||
features: [
|
||||
'Notes illimitées',
|
||||
'BYOK (OpenAI/Anthropic)',
|
||||
'200 recherches sémantiques',
|
||||
'Agents (12 runs/mois)',
|
||||
'Historique 30 jours',
|
||||
'Support Email'
|
||||
],
|
||||
current: false,
|
||||
popular: true,
|
||||
buttonText: 'Passer au Plan Pro',
|
||||
buttonClass: 'bg-accent text-white shadow-xl shadow-accent/20 hover:scale-[1.02] active:scale-95'
|
||||
},
|
||||
{
|
||||
id: 'business',
|
||||
name: 'Plan Business',
|
||||
price: '29,90€',
|
||||
period: '/mois',
|
||||
description: 'Pour les équipes et chefs de produit.',
|
||||
features: [
|
||||
'10 Collaborateurs inclus',
|
||||
'BYOK (13 fournisseurs)',
|
||||
'1000 recherches sémantiques',
|
||||
'Agents (60 runs/mois)',
|
||||
'Brainstorm illimité',
|
||||
'Accès API'
|
||||
],
|
||||
current: false,
|
||||
buttonText: 'Choisir Plan Business',
|
||||
buttonClass: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-12"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">Gérer votre abonnement et votre facturation</h3>
|
||||
</div>
|
||||
|
||||
{/* Usage Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-accent/10 text-accent rounded-xl">
|
||||
<Activity size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-ink">Utilisation actuelle</h4>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest">Période en cours</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-[11px] font-medium text-concrete uppercase tracking-wider">
|
||||
<span>Crédits IA</span>
|
||||
<span>1 / 50 utilisés</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent w-[2%] rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-[11px] font-medium text-concrete uppercase tracking-wider">
|
||||
<span>Notes & Carnets</span>
|
||||
<span>12 / 100 notes</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-ochre w-[12%] rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-paper dark:bg-white/10 text-concrete rounded-xl">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-ink">Facturation</h4>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest">Renouvellement</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-concrete font-light">Votre plan gratuit n'expire jamais. Passez à la vitesse supérieure pour débloquer toute la puissance de Momento.</p>
|
||||
<div className="pt-4 flex items-center justify-between border-t border-border/40 mt-4">
|
||||
<span className="text-[11px] font-bold text-ink uppercase tracking-widest">Plan Actuel</span>
|
||||
<span className="text-[11px] font-bold text-accent uppercase tracking-widest">GRATUIT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan Selection */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`relative p-8 rounded-[40px] border transition-all duration-500 overflow-hidden group flex flex-col
|
||||
${plan.popular
|
||||
? 'bg-white dark:bg-paper border-accent shadow-2xl shadow-accent/10 scale-105 z-10'
|
||||
: 'bg-white/40 dark:bg-white/5 border-border hover:border-concrete/30'}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute top-0 right-0 py-1.5 px-6 bg-accent text-white text-[9px] font-bold uppercase tracking-widest rounded-bl-2xl">
|
||||
Recommandé
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8 space-y-2">
|
||||
<h4 className="text-xl font-serif font-bold text-ink">{plan.name}</h4>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-serif font-bold text-ink">{plan.price}</span>
|
||||
<span className="text-concrete text-xs font-light italic">{plan.period}</span>
|
||||
</div>
|
||||
<p className="text-xs text-concrete font-light leading-relaxed pr-4">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-10 flex-1">
|
||||
{plan.features.map((feature, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div className={`mt-1 p-0.5 rounded-full ${plan.popular ? 'bg-accent/10 text-accent' : 'bg-concrete/10 text-concrete'}`}>
|
||||
<Check size={10} />
|
||||
</div>
|
||||
<span className="text-xs font-light text-ink/80">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`w-full py-4 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300 ${plan.buttonClass}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{plan.buttonText}
|
||||
{!plan.current && <ArrowRight size={14} />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="bg-slate-50 dark:bg-black/20 rounded-[32px] p-8 border border-border/40 flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-white dark:bg-paper rounded-2xl shadow-sm border border-border">
|
||||
<Shield size={24} className="text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-sm font-bold text-ink">Transactions sécurisées</h5>
|
||||
<p className="text-xs text-concrete font-light">Paiement via Stripe. Annulez à tout moment, sans engagement.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={16} className="text-ochre" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Activation instantanée</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Crown size={16} className="text-amber-500" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Garantie satisfait</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
82
architectural-grid1/src/components/settings/GeneralTab.tsx
Normal file
82
architectural-grid1/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-accent/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>
|
||||
);
|
||||
};
|
||||
92
architectural-grid1/src/components/settings/ProfileTab.tsx
Normal file
92
architectural-grid1/src/components/settings/ProfileTab.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { User, Mail, Shield, LogOut, Camera, Bell } from 'lucide-react';
|
||||
|
||||
interface ProfileTabProps {
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export const ProfileTab: React.FC<ProfileTabProps> = ({ onLogout }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="space-y-12 max-w-2xl"
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="relative group">
|
||||
<div className="w-24 h-24 rounded-[32px] bg-accent/10 border-2 border-accent/20 flex items-center justify-center text-accent overflow-hidden">
|
||||
<User size={40} />
|
||||
</div>
|
||||
<button className="absolute -bottom-2 -right-2 p-2 bg-ink text-white rounded-xl shadow-lg border border-border opacity-0 group-hover:opacity-100 transition-all hover:scale-110">
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-2xl font-serif font-bold text-ink">Sepehr</h3>
|
||||
<p className="text-sm text-concrete font-light">Membre Pro depuis Mai 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete opacity-60">Informations personnelles</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Mail size={18} className="text-concrete" />
|
||||
<div>
|
||||
<p className="text-[10px] uppercase font-bold text-concrete tracking-widest">Email</p>
|
||||
<p className="text-sm text-ink">sepehr1151@gmail.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-[10px] font-bold text-accent uppercase tracking-widest hover:underline">Modifier</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Shield size={18} className="text-concrete" />
|
||||
<div>
|
||||
<p className="text-[10px] uppercase font-bold text-concrete tracking-widest">Sécurité</p>
|
||||
<p className="text-sm text-ink">Authentification à deux facteurs</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-[10px] font-bold text-accent uppercase tracking-widest hover:underline">Activer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete opacity-60">Préférences de compte</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-white/40 dark:bg-white/5 border border-border rounded-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Bell size={18} className="text-concrete" />
|
||||
<div>
|
||||
<p className="text-sm text-ink">Notification push</p>
|
||||
<p className="text-[10px] text-concrete font-light pr-4">Recevez des alertes pour vos rappels et activités IA.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-10 h-5 bg-accent rounded-full relative p-1 cursor-pointer">
|
||||
<div className="absolute right-1 top-1 w-3 h-3 bg-white rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 border-t border-border/40">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex items-center gap-3 px-6 py-3 bg-rose-50 dark:bg-rose-500/10 text-rose-600 rounded-xl font-bold uppercase tracking-widest text-[10px] hover:bg-rose-100 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Settings, Sparkles, Palette, User, Database, Code, Info, CreditCard, Menu } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { SettingsTab } from '../../types';
|
||||
|
||||
interface SettingsHeaderProps {
|
||||
activeTab: SettingsTab;
|
||||
setActiveTab: (tab: SettingsTab) => void;
|
||||
onOpenSidebar?: () => void;
|
||||
}
|
||||
|
||||
export const SettingsHeader: React.FC<SettingsHeaderProps> = ({ activeTab, setActiveTab, onOpenSidebar }) => {
|
||||
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: 'billing', label: 'Facturation', icon: <CreditCard 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-6 sm:px-12 pt-12 sm:pt-20 pb-16 space-y-12 relative">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onOpenSidebar}
|
||||
className="lg:hidden p-2 -ml-2 text-ink hover:bg-black/5 rounded-lg transition-colors"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-4xl sm: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>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-1 border-b border-border/40 pb-px overflow-x-auto no-scrollbar">
|
||||
{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-grid1/src/constants.ts
Normal file
93
architectural-grid1/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.\n\n- [ ] Finaliser l\'étude de la géométrie sacrée\n- [x] Tracer les grilles orthogonales préliminaires',
|
||||
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.\n\n- [ ] Valider l\'algorithme de déformation spatiale\n- [ ] Tester les nœuds structurels imprimés en 3D',
|
||||
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.\n\n- [ ] Contacter le fournisseur de CLT local\n- [ ] Estimer le ratio d\'émission de carbone évitée',
|
||||
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.\n\n- [x] Simuler l\'ensoleillement d\'hiver sur la façade sud\n- [ ] Calculer l\'épaisseur d\'isolation en fibre de bois',
|
||||
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-grid1/src/index.css
Normal file
98
architectural-grid1/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-accent: #A47148; /* Warm Earthy Brown */
|
||||
--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-grid1/src/main.tsx
Normal file
10
architectural-grid1/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
242
architectural-grid1/src/services/clusteringService.ts
Normal file
242
architectural-grid1/src/services/clusteringService.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function getMostCentralNoteTitles(noteIds: string[], centroid: number[] | undefined, allNotes: Note[], count: number = 5): string[] {
|
||||
const clusterNotes = allNotes.filter(n => noteIds.includes(n.id) && n.embedding);
|
||||
if (clusterNotes.length === 0) return [];
|
||||
if (!centroid) return clusterNotes.slice(0, count).map(n => n.title);
|
||||
|
||||
const scored = clusterNotes.map(n => ({
|
||||
title: n.title,
|
||||
similarity: cosineSimilarity(n.embedding!, centroid)
|
||||
}));
|
||||
|
||||
scored.sort((a, b) => b.similarity - a.similarity);
|
||||
return scored.slice(0, count).map(item => item.title);
|
||||
}
|
||||
313
architectural-grid1/src/services/geminiService.ts
Normal file
313
architectural-grid1/src/services/geminiService.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
const parsed = JSON.parse(response.text);
|
||||
return Array.isArray(parsed.suggestions) ? parsed.suggestions : [];
|
||||
} catch (error) {
|
||||
console.error("Error suggesting bridge ideas:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export async function parseDocument(fileUrl: string, fileName: string): Promise<string> {
|
||||
const prompt = `Extraits et résume le texte de ce document nommé "${fileName}".
|
||||
Si c'est un PDF, ignore les éléments purement graphiques et concentre-toi sur le contenu sémantique.
|
||||
Fais une extraction structurée.`;
|
||||
|
||||
try {
|
||||
// In a real scenario, we would use media upload.
|
||||
// Here we simulate the extraction.
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
systemInstruction: "Tu es un expert en extraction de texte et analyse de documents.",
|
||||
temperature: 0.2
|
||||
}
|
||||
});
|
||||
|
||||
return response.text || "Échec de l'extraction du texte.";
|
||||
} catch (error) {
|
||||
console.error("Error parsing document:", error);
|
||||
return "Erreur lors de l'analyse du document.";
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractActionItems(notes: { title: string; content: string }[]): Promise<string> {
|
||||
const notesContext = notes.map(n => `TITLE: ${n.title}\nCONTENT: ${n.content}`).join('\n\n---\n\n');
|
||||
const prompt = `
|
||||
Analyse les notes suivantes et extrais la liste des actions à accomplir (TODOs).
|
||||
Pour chaque tâche, identifie si possible l'assigné et la date limite.
|
||||
Présente le résultat sous forme d'un tableau Markdown structuré ou d'une liste claire.
|
||||
Si aucune tâche n'est trouvée, indique-le.
|
||||
|
||||
Notes:
|
||||
${notesContext}
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt,
|
||||
config: {
|
||||
systemInstruction: "Tu es un agent spécialisé dans l'organisation et la gestion de tâches. Ton but est d'être précis et exhaustif.",
|
||||
temperature: 0.1
|
||||
}
|
||||
});
|
||||
|
||||
return response.text;
|
||||
} catch (error) {
|
||||
console.error("Error extracting action items:", error);
|
||||
return "Erreur lors de l'extraction des tâches.";
|
||||
}
|
||||
}
|
||||
|
||||
const FLASHCARDS_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
flashcards: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
question: { type: Type.STRING },
|
||||
answer: { type: Type.STRING }
|
||||
},
|
||||
required: ["question", "answer"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["flashcards"]
|
||||
};
|
||||
|
||||
export async function generateFlashcardsForNote(
|
||||
noteTitle: string,
|
||||
noteContent: string
|
||||
): Promise<{ question: string; answer: string }[]> {
|
||||
const prompt = `
|
||||
Titre de la note : "${noteTitle}"
|
||||
Contenu de la note :
|
||||
${noteContent}
|
||||
|
||||
Génère entre 4 et 8 flashcards (paires question/réponse) d'apprentissage basées sur le contenu ci-dessus.
|
||||
|
||||
Règles de style :
|
||||
- Les questions doivent être claires et guider vers une révision active (ex: "Quelle est la particularité de... ?", "Pourquoi utilise-t-on... ?").
|
||||
- Les réponses doivent être courtes et percutantes.
|
||||
- Langue : Français.
|
||||
- Format de retour : JSON correspondant au schéma.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-3.5-flash",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
systemInstruction: "Tu es un assistant de révision agile. Tu convertis le contenu d'un cours ou d'une note en de superbes flashcards mémo-techniques.",
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: FLASHCARDS_SCHEMA,
|
||||
temperature: 0.7
|
||||
}
|
||||
});
|
||||
|
||||
const resText = response.text;
|
||||
if (!resText) return [];
|
||||
|
||||
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
|
||||
return Array.isArray(parsed.flashcards) ? parsed.flashcards : (Array.isArray(parsed) ? parsed : []);
|
||||
} catch (error) {
|
||||
console.error("Error generating flashcards with Gemini:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
76
architectural-grid1/src/services/temporalService.ts
Normal file
76
architectural-grid1/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));
|
||||
}
|
||||
153
architectural-grid1/src/types.ts
Normal file
153
architectural-grid1/src/types.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
export type NavigationView = 'notebooks' | 'agents' | 'settings' | 'shared' | 'reminders' | 'trash' | 'brainstorm' | 'insights' | 'temporal' | 'graph' | 'revision';
|
||||
export type AITone = 'Professional' | 'Creative' | 'Academic' | 'Casual';
|
||||
export type AITab = 'infos' | 'versions' | 'relations' | 'discussion' | 'actions' | 'resources' | 'explore';
|
||||
export type SettingsTab = 'general' | 'ai' | 'billing' | 'appearance' | 'profile' | 'data' | 'mcp' | 'about';
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'ai' | 'user';
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'pdf' | 'docx' | 'image' | 'other';
|
||||
url: string;
|
||||
content?: string; // Extracted text
|
||||
isProcessed?: boolean;
|
||||
}
|
||||
|
||||
export interface NoteVersion {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
carnetId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
imageUrl: string;
|
||||
date: string;
|
||||
tags: Tag[];
|
||||
attachments?: Attachment[];
|
||||
isPinned?: boolean;
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: string;
|
||||
embedding?: number[];
|
||||
clusterId?: string;
|
||||
isClipped?: boolean;
|
||||
clipSourceUrl?: string;
|
||||
clipFavicon?: string;
|
||||
clipDate?: string;
|
||||
isVersioningEnabled?: boolean;
|
||||
versionHistory?: NoteVersion[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export type FlashcardEvaluation = 'fail' | 'hesitant' | 'sure';
|
||||
|
||||
export interface Flashcard {
|
||||
id: string;
|
||||
noteId: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
intervalDays: number; // For spaced repetition
|
||||
nextReviewDate: string; // ISO String
|
||||
easeFactor: number;
|
||||
mastered: boolean;
|
||||
history?: {
|
||||
reviewedAt: string;
|
||||
evaluation: FlashcardEvaluation;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface FlashcardDeck {
|
||||
noteId: string;
|
||||
title: string;
|
||||
cardsCount: number;
|
||||
nextReviewDate: string; // Min nextReviewDate of all cards
|
||||
masteryScore: number; // Proportion of cards evaluation === 'sure'
|
||||
cards: Flashcard[];
|
||||
}
|
||||
|
||||
|
||||
26
architectural-grid1/tsconfig.json
Normal file
26
architectural-grid1/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
24
architectural-grid1/vite.config.ts
Normal file
24
architectural-grid1/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig, loadEnv} from 'vite';
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -58,3 +58,7 @@ development_status:
|
||||
4-5-eu-data-residency: backlog
|
||||
4-6-sso-saml-audit-logging: backlog
|
||||
epic-4-retrospective: optional
|
||||
epic-5: in-progress
|
||||
5-1-nextgen-editor: ready-for-dev
|
||||
|
||||
|
||||
|
||||
118
docs/story-nextgen-editor.md
Normal file
118
docs/story-nextgen-editor.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Story: Éditeur Next-Gen — Poignée Gutter & Base de Données Inline
|
||||
|
||||
> **Epic:** Éditeur de Notes et Saisie Intuitive (Next-Gen)
|
||||
> **Priority:** High
|
||||
> **Status:** ready-for-dev
|
||||
> **Depends on:** US-LIVING-BLOCKS (UniqueID et transclusion), US-STRUCTURED-VIEWS (NotebookSchema et propriétés)
|
||||
> **Blocks:** —
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
L'éditeur de notes actuel de Momento est construit sur [rich-text-editor.tsx](file:///home/devparsa/dev/Momento/memento-note/components/rich-text-editor.tsx) avec Tiptap/ProseMirror. Bien qu'il supporte des fonctionnalités avancées (Blocs Vivants, Résonance Sémantique), l'interaction utilisateur de saisie reste celle d'un traitement de texte classique (document linéaire à curseur unique).
|
||||
|
||||
Pour offrir une expérience de saisie supérieure à celle de Notion (plus performante, plus fluide à l'écriture) tout en conservant les avantages de la manipulation de blocs, nous implémentons une approche hybride :
|
||||
1. **Gutter & Drag Handle Flottant :** Au lieu d'avoir un composant React lourd pour chaque paragraphe (comme dans Notion), un unique bouton de poignée de glissement suit le curseur de la souris dans la marge de l'éditeur en ProseMirror pur, éliminant tout décalage à la saisie.
|
||||
2. **Transclusion au Collage :** Faciliter la création de Blocs Vivants en interceptant les liens de blocs copiés et en proposant de les coller en tant que transclusion synchrone.
|
||||
3. **Bloc Database Inline :** Porter le composant de base de données relationnelle du prototype [ModernBlockNoteEditor.tsx](file:///home/devparsa/dev/Momento/architectural-grid1/src/components/ModernBlockNoteEditor.tsx#L1711) pour permettre aux utilisateurs d'insérer des tableaux/fiches interactives avec Rollups dynamiques directement au sein de leurs notes.
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
### US-1: Poignée de Glissement Gutter Unique (Hover Drag Handle)
|
||||
**En tant que** rédacteur,
|
||||
**Je veux** voir apparaître une poignée de glissement discrète dans la marge gauche du bloc que je survole avec ma souris,
|
||||
**Afin de** pouvoir réordonner mes blocs par glisser-déposer de manière fluide.
|
||||
|
||||
#### Critères d'Acceptation :
|
||||
* **Étant donné** que j'ai une note ouverte dans l'éditeur de Momento
|
||||
* **Quand** je survole une ligne de texte (paragraphe, titre, liste, citation, etc.) avec mon curseur de souris
|
||||
* **Alors** une poignée de glissement (`::drag-handle` flottante) apparaît dans le gutter gauche à la hauteur exacte du bloc survolé
|
||||
* **Et** le bouton suit mes déplacements de souris d'un bloc à l'autre sans latence et sans dupliquer les nœuds DOM (une seule poignée réutilisée)
|
||||
* **Quand** je clique-glisse sur cette poignée
|
||||
* **Alors** le bloc ProseMirror entier sous-jacent est sélectionné visuellement et je peux le déplacer vers le haut ou vers le bas, avec un indicateur visuel de la ligne d'insertion
|
||||
* **Et** la poignée est masquée sur les terminaux mobiles / écrans tactiles pour éviter d'entraver l'ergonomie.
|
||||
|
||||
---
|
||||
|
||||
### US-2: Menu Action Rapide de Bloc
|
||||
**En tant que** créateur de contenu,
|
||||
**Je veux** pouvoir cliquer sur la poignée de bloc pour ouvrir un menu contextuel d'actions rapides,
|
||||
**Afin de** manipuler la structure de mes documents sans avoir à utiliser les sélections de texte complexes ou des raccourcis clavier obscurs.
|
||||
|
||||
#### Critères d'Acceptation :
|
||||
* **Étant donné** que la poignée de bloc est visible à côté d'un bloc
|
||||
* **Quand** je clique sur cette poignée
|
||||
* **Alors** un menu contextuel (dropdown à base de glassmorphism fluide) apparaît
|
||||
* **Et** il propose les options suivantes :
|
||||
* `Supprimer` -> Efface le bloc ProseMirror ciblé
|
||||
* `Dupliquer` -> Duplique le bloc ProseMirror immédiatement en dessous
|
||||
* `Transformer en` -> Sous-menu pour convertir en : Titre 1, Titre 2, Titre 3, Liste à puces, Liste numérotée, Liste de tâches, Citation, Bloc de code, ou Base de données
|
||||
* `Copier la référence` -> Génère/copie le lien unique du bloc (utilisant son `UniqueID` Tiptap) dans le presse-papier.
|
||||
|
||||
---
|
||||
|
||||
### US-3: Transclusion intelligente au Collage (Smart Paste)
|
||||
**En tant que** chercheur,
|
||||
**Je veux** pouvoir coller un lien de bloc copié et le transformer instantanément en bloc connecté synchrone,
|
||||
**Afin de** lier et synchroniser des informations à travers différentes notes sans effort manuel.
|
||||
|
||||
#### Critères d'Acceptation :
|
||||
* **Étant donné** que j'ai copié la référence d'un bloc (contenant l'ID de la note source et le `blockId` unique du paragraphe)
|
||||
* **Quand** je colle (Ctrl+V ou Cmd+V) ce lien dans un paragraphe vide d'une autre note
|
||||
* **Alors** un petit menu interactif en ligne s'affiche : *"Coller en tant que Bloc Connecté (Live)"* ou *"Coller en tant que texte / lien simple"*
|
||||
* **Quand** je sélectionne *"Bloc Connecté"*,
|
||||
* **Alors** le bloc ProseMirror est remplacé par un nœud de type `liveBlock` (provenant de notre [LiveBlockExtension](file:///home/devparsa/dev/Momento/memento-note/components/tiptap-live-block-extension.tsx#L159)) synchronisant le contenu en temps réel.
|
||||
|
||||
---
|
||||
|
||||
### US-4: Bloc de Base de Données Relationnelle Inline
|
||||
**En tant que** chef de projet ou gestionnaire de données,
|
||||
**Je veux** pouvoir insérer une base de données relationnelle interactive directement au milieu de mon texte, basculer ses vues et suivre des rollups,
|
||||
**Afin de** structurer mes informations complexes sans quitter le flux de ma note (comme simulé dans le prototype).
|
||||
|
||||
#### Critères d'Acceptation :
|
||||
* **Étant donné** que j'utilise l'éditeur de Momento
|
||||
* **Quand** je saisis `/database` ou sélectionne "Base de données" dans le menu d'insertion
|
||||
* **Alors** un bloc `databaseBlock` est inséré sous forme d'un React NodeView
|
||||
* **Et** il propose par défaut un modèle relationnel "Auteurs & Œuvres" (avec des données simulées ou liées au carnet actuel)
|
||||
* **Quand** je clique sur le sélecteur de vue en haut à droite du bloc
|
||||
* **Alors** je peux basculer entre la vue **Tableau** (grille avec colonnes de propriétés) et la vue **Fiches** (cartes illustrées de couvertures, tags et auteurs)
|
||||
* **Et** la colonne **Rollup Count** de la vue Tableau recalcule dynamiquement le nombre de livres liés à chaque auteur au fur et à mesure que j'ajoute ou supprime des fiches
|
||||
* **Et** un formulaire d'insertion rapide en bas du bloc me permet d'ajouter des livres ou des auteurs à ce modèle relationnel local.
|
||||
|
||||
---
|
||||
|
||||
## Spécifications Techniques d'Implémentation
|
||||
|
||||
### 1. Structure du Nœud de Base de Données Tiptap (`DatabaseBlock`)
|
||||
Créer une extension Tiptap personnalisée `DatabaseBlockExtension` dans `/components/tiptap-database-block-extension.tsx` qui :
|
||||
- Gère un groupe `block`, se comporte comme un atome (non modifiable directement en texte brut).
|
||||
- Conserve les attributs : `dbId` (string), `dbView` ('table' | 'card'), `dbAuthors` (array) et `dbBooks` (array) représentant le schéma et les valeurs.
|
||||
- Utilise un `ReactNodeViewRenderer` pour monter notre composant d'édition de base de données relationnelle en React pur.
|
||||
|
||||
### 2. Le Plugin Drag Handle & Gutter en ProseMirror
|
||||
Développer un plugin custom dans `/components/tiptap-drag-handle-plugin.ts` :
|
||||
* Attacher un écouteur d'événements `mouseover` et `mousemove` sur l'éditeur ProseMirror.
|
||||
* Utiliser `view.posAtCoords` pour localiser le nœud ProseMirror survolé.
|
||||
* Récupérer son DOM parent et calculer sa hauteur et son décalage vertical.
|
||||
* Repositionner l'élément HTML de la poignée en modifiant ses styles CSS `top` et `left` (le bouton est monté au niveau de l'éditeur parent en absolute, évitant d'être injecté dans le texte éditable).
|
||||
* Gérer le glissement avec l'API HTML5 Drag and Drop couplée aux transactions de ProseMirror (`tr.replaceWith` ou `MoverBlock`).
|
||||
|
||||
### 3. Fichiers à modifier / créer :
|
||||
* `[NEW]` `memento-note/components/tiptap-drag-handle-plugin.ts` — Plugin de poignée Gutter
|
||||
* `[NEW]` `memento-note/components/tiptap-database-block-extension.tsx` — Nœud et composant DatabaseBlockEditor
|
||||
* `[MODIFY]` `memento-note/components/rich-text-editor.tsx` — Intégration du Drag Handle, de la DatabaseBlockExtension et de l'interception du presse-papier dans `editorProps.handlePaste`
|
||||
* `[MODIFY]` `memento-note/app/globals.css` — Ajout des classes CSS pour l'alignement précis du gutter, de la poignée, et du bloc de base de données (glassmorphic dropdowns et fiches).
|
||||
|
||||
---
|
||||
|
||||
## Plan de Vérification
|
||||
|
||||
### Tests Manuels :
|
||||
1. **Vérification Gutter :** Survoler des textes longs et vérifier que la poignée se positionne correctement à gauche. Glisser un paragraphe sur un autre et valider le réordonnancement.
|
||||
2. **Vérification Menu :** Cliquer sur la poignée, dupliquer le bloc, supprimer le bloc, et le transformer en d'autres types.
|
||||
3. **Vérification Paste :** Copier une référence de bloc, la coller, et vérifier que la transclusion est proposée et s'insère sous forme de `LiveBlock`.
|
||||
4. **Vérification Base de Données Inline :** Insérer le bloc base de données, ajouter un auteur, ajouter un livre avec cet auteur, vérifier le bon calcul du rollup, et basculer en vue Fiches pour vérifier l'affichage des couvertures.
|
||||
@@ -1,7 +1,7 @@
|
||||
# User Stories — Momento Next Phase
|
||||
|
||||
> Basé sur l'analyse du prototype `architectural-grid/` et du code production `memento-note/`.
|
||||
> Dernière mise à jour : 2026-05-24
|
||||
> Dernière mise à jour : 2026-05-25 (US-NEXTGEN-EDITOR réorganisé, 4 nouvelles stories éditeur ajoutées)
|
||||
|
||||
---
|
||||
|
||||
@@ -16,25 +16,44 @@
|
||||
| **US-INFO-RÉSEAU** | Panneau Info + Réseau Local | ✅ **LIVRÉ** | `note-network-tab.tsx`, `sync-note-links.ts`, migration `NoteLink`, picker `[[` |
|
||||
| **US-CLIPPER** | Web Clipper | ✅ **LIVRÉ** | `extension/`, `/api/clip/*`, migration `sourceUrl`, badge panneau Info |
|
||||
| **US-GRAPH** | Graphe de Connaissance Global enrichi | ✅ **LIVRÉ** | `note-graph-view.tsx` — filtres liens, seuil sémantique, focus voisinage, couleurs carnets, double-clic ouverture |
|
||||
| **US-INSIGHTS** | Clusters Sémantiques + Bridge Notes | 🚧 **EN COURS** | clusters en base mais page masquait les résultats périmés — correction affichage |
|
||||
| **US-TEMPORAL** | Prédictions d'accès temporelles | ⏳ À faire | — |
|
||||
| **US-INSIGHTS** | Clusters Sémantiques + Bridge Notes | ✅ **LIVRÉ** | `app/(main)/insights/page.tsx`, `network-graph.tsx`, `/api/clusters`, `/api/bridge-notes/*`, état dégradé si clusters périmés |
|
||||
| **US-TEMPORAL** | Prédictions d'accès temporelles | ⏸️ **REPORTÉ** | Remplacé par rappels + révision SM-2 + Memory Echo ; heuristique faible, migration NoteAccessLog non prioritaire |
|
||||
| **US-FLASHCARDS** | Révision IA — Répétition espacée SM-2 | ✅ **LIVRÉ** | `/revision`, `/api/flashcards/*`, SM-2, génération IA depuis l'éditeur |
|
||||
| **US-STRUCTURED-VIEWS** | Vues Structurées (Tableau/Kanban/Galerie) | ✅ **LIVRÉ** | `/api/notebooks/[id]/schema`, `/api/notes/[id]/properties`, vues structurées + panneau propriétés éditeur |
|
||||
| **US-NEXTGEN-EDITOR** | Éditeur Next-Gen : Drag Handle + Menu Bloc + DB Inline + Smart Paste | 🚧 **PLANIFIÉ** | Voir `docs/story-nextgen-editor.md` |
|
||||
| **US-EDITOR-PERF** | Performance de frappe TipTap (quick wins) | 🚧 **PLANIFIÉ** | — |
|
||||
| **US-EDITOR-UX** | Micro-interactions saisie (slash menu, sélection multi-blocs, paste étendu, placeholders) | ⏳ **À FAIRE** | — |
|
||||
| **US-EDITOR-MOBILE** | Expérience tactile & toolbar mobile adaptée | ⏳ **À FAIRE** | — |
|
||||
| **US-EDITOR-MARKDOWN** | Rendu WYSIWYG Markdown fidèle (round-trip byte-for-byte) | ⏳ **À FAIRE** | — |
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'implémentation (dépendances)
|
||||
|
||||
```
|
||||
US-LIVING-BLOCKS ← dépend de : TipTap UniqueID (fondation)
|
||||
US-MEMORY-ECHO ← dépend de : pgvector existant (aucune migration)
|
||||
US-INFO-RÉSEAU ← dépend de : wikilinks + backlinks existants
|
||||
US-CLIPPER ← dépend de : migration Note.sourceUrl
|
||||
US-GRAPH ← dépend de : /api/graph existant
|
||||
US-INSIGHTS ← dépend de : Memory Echo + clusters
|
||||
US-TEMPORAL ← dépend de : migration NoteAccessLog
|
||||
US-FLASHCARDS ← dépend de : migration FlashcardDeck + Flashcard
|
||||
US-STRUCTURED-VIEWS ← dépend de : migration NotebookSchema + NotebookProperty
|
||||
Quick wins (en premier, ~2h) :
|
||||
US-EDITOR-PERF <- depend de : rien (modifs config TipTap)
|
||||
|
||||
Editeur Next-Gen (bloc principal) :
|
||||
US-NEXTGEN-EDITOR <- depend de : US-LIVING-BLOCKS, US-STRUCTURED-VIEWS
|
||||
US-EDITOR-UX <- depend de : US-NEXTGEN-EDITOR (drag handle + menu bloc en place)
|
||||
US-EDITOR-MOBILE <- depend de : US-NEXTGEN-EDITOR (drag handle existant)
|
||||
|
||||
Plus tard :
|
||||
US-EDITOR-MARKDOWN <- depend de : rien (evaluation Milkdown, low priority)
|
||||
|
||||
Stories livrees :
|
||||
US-LIVING-BLOCKS <- depend de : TipTap UniqueID (fondation)
|
||||
US-MEMORY-ECHO <- depend de : pgvector existant
|
||||
US-INFO-RESEAU <- depend de : wikilinks + backlinks
|
||||
US-CLIPPER <- depend de : migration Note.sourceUrl
|
||||
US-GRAPH <- depend de : /api/graph existant
|
||||
US-INSIGHTS <- depend de : Memory Echo + clusters
|
||||
US-FLASHCARDS <- depend de : migration FlashcardDeck + Flashcard
|
||||
US-STRUCTURED-VIEWS <- depend de : migration NotebookSchema + NotebookProperty
|
||||
|
||||
Reportees :
|
||||
US-TEMPORAL <- depend de : migration NoteAccessLog (REPORTE)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -539,6 +558,8 @@ model FlashcardReview {
|
||||
|
||||
## US-TEMPORAL — Prédictions d'Accès Temporelles
|
||||
|
||||
> **Statut : ⏸️ REPORTÉ (2026-05-24)** — Chevauche rappels, révision SM-2 et Memory Echo ; faible signal sans volume d'ouvertures ; pas prioritaire pour éviter la surcharge produit. Spec conservée pour réévaluation ultérieure éventuelle.
|
||||
|
||||
**Contexte :**
|
||||
`TemporalView.tsx` (169L) prédit quelles notes l'utilisateur voudra relire, basé sur des patterns d'accès. Rien n'existe en prod. Feature légère à fort impact perçu.
|
||||
|
||||
@@ -569,3 +590,231 @@ model NoteAccessLog {
|
||||
- Liste des 3 meilleures prédictions
|
||||
- Badge `cyclique` / `tendance` selon le type
|
||||
- Clic → ouvre la note
|
||||
|
||||
---
|
||||
|
||||
## US-NEXTGEN-EDITOR — Éditeur Next-Gen : Drag Handle + Menu Bloc + DB Inline + Smart Paste
|
||||
|
||||
> **Status :** PLANIFIÉ
|
||||
> **Depends on :** US-LIVING-BLOCKS (UniqueID et transclusion), US-STRUCTURED-VIEWS (NotebookSchema et propriétés)
|
||||
> **Spec détaillée :** `docs/story-nextgen-editor.md` (4 sous-stories : US-1 Drag Handle, US-2 Menu Action Bloc, US-3 Smart Paste, US-4 Database Inline)
|
||||
|
||||
**Contexte :**
|
||||
L'éditeur actuel est un document linéaire classique. Pour rivaliser avec Notion tout en étant plus performant, on implémente une approche hybride : un unique bouton de poignée en ProseMirror pur (pas de composant React lourd par paragraphe), un menu contextuel de bloc, une transclusion au collage, et un bloc database inline.
|
||||
|
||||
**4 sous-stories** (détail dans `docs/story-nextgen-editor.md`) :
|
||||
|
||||
### US-1 : Poignée de Glissement Gutter Unique (Hover Drag Handle)
|
||||
- Bouton flottant unique dans la marge gauche, suit le curseur de bloc en bloc
|
||||
- Un seul élément DOM repositionné (pas de duplication)
|
||||
- Drag & drop de blocs avec indicateur de ligne d'insertion
|
||||
- Masqué sur mobile/tactile
|
||||
|
||||
### US-2 : Menu Action Rapide de Bloc
|
||||
- Clic sur la poignée -> dropdown glassmorphism
|
||||
- Actions : Supprimer, Dupliquer, Transformer en (H1/H2/H3/liste/todo/citation/code/database), Copier la référence
|
||||
- Utilise le `UniqueID` TipTap pour les références stables
|
||||
|
||||
### US-3 : Transclusion intelligente au Collage (Smart Paste)
|
||||
- Collage d'un lien de bloc -> menu inline : "Bloc Connecté (Live)" ou "Texte simple"
|
||||
- Insère un nœud `liveBlock` synchronisé via Redis Pub/Sub
|
||||
|
||||
### US-4 : Bloc de Base de Données Relationnelle Inline
|
||||
- Slash `/database` -> insère un React NodeView
|
||||
- Vues Tableau / Fiches avec Rollup dynamique
|
||||
- Modèle relationnel local (auteurs/livres par défaut, ou lié au carnet)
|
||||
|
||||
### Fichiers
|
||||
- `[NEW]` `tiptap-drag-handle-plugin.ts` — Plugin ProseMirror pur
|
||||
- `[NEW]` `tiptap-database-block-extension.tsx` — NodeView React
|
||||
- `[MODIFY]` `rich-text-editor.tsx` — Intégration drag handle + DB + paste intercept
|
||||
- `[MODIFY]` `globals.css` — Gutter, poignée, glassmorphic dropdowns
|
||||
|
||||
---
|
||||
|
||||
## US-EDITOR-PERF — Performance de Frappe TipTap (Quick Wins)
|
||||
|
||||
> **Status :** PLANIFIÉ
|
||||
> **Depends on :** rien (modifications config TipTap, ~2h)
|
||||
> **Priorité :** HAUTE — impact immédiat sur le ressenti de saisie
|
||||
> **Source recherche :** TipTap 2.5 (mai 2026), TipTap docs performance, PR #7828
|
||||
|
||||
**Contexte :**
|
||||
Actuellement `rich-text-editor.tsx` utilise `immediatelyRender: false` mais pas `shouldRerenderOnTransaction` ni `useEditorState`. TipTap re-render le composant React à chaque transaction (frappe, déplacement curseur, sélection) — ce qui ajoute de la latence. Obsidian atteint <16ms de latence (local-first), Notion 50-150ms (cloud). Momento est local mais se comporte comme Notion à cause de ces re-renders inutiles.
|
||||
|
||||
**En tant qu'utilisateur**, je veux que la frappe dans l'éditeur soit instantanée, sans aucun décalage perceptible, même sur des notes longues avec de nombreux blocs.
|
||||
|
||||
### 1. shouldRerenderOnTransaction: false (1 ligne)
|
||||
```typescript
|
||||
// rich-text-editor.tsx — useEditor()
|
||||
const editor = useEditor({
|
||||
extensions: [...],
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false, // <-- AJOUTER
|
||||
// ...
|
||||
})
|
||||
```
|
||||
- Le composant React EditorContent ne se re-render plus sur chaque transaction
|
||||
- Seul le DOM ProseMirror est mis à jour (ultra-rapide, natif)
|
||||
- Gain mesurable : de ~50-100ms par frappe à <16ms
|
||||
|
||||
### 2. useEditorState pour la toolbar et les panels
|
||||
```typescript
|
||||
import { useEditorState } from '@tiptap/react'
|
||||
|
||||
// Au lieu de useEditor + editor.isActive() dans le render :
|
||||
const { isBold, isItalic, isHeading } = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => ({
|
||||
isBold: ctx.editor.isActive('bold'),
|
||||
isItalic: ctx.editor.isActive('italic'),
|
||||
isHeading: ctx.editor.isActive('heading'),
|
||||
}),
|
||||
})
|
||||
```
|
||||
- La toolbar et le panneau propriétés ne se re-rendent que quand leur slice d'état change
|
||||
- Actuellement, tout le composant editor re-render à chaque frappe -> la toolbar aussi
|
||||
|
||||
### 3. Isoler l'éditeur dans un composant dédié
|
||||
- Créer `NoteEditorCore` (composant wrapper) qui ne reçoit que les props strictement nécessaires
|
||||
- Les re-renders du parent (note-panel, sidebar ouverture, etc.) ne doivent PAS propager dans l'éditeur
|
||||
- React.memo sur le wrapper si besoin
|
||||
|
||||
### 4. NodeViews : trackNodeViewPosition: false par défaut
|
||||
- Les NodeViews React (LiveBlock, Chart, DatabaseBlock) ne doivent pas se re-re-render quand seul leur position dans le document change
|
||||
- TipTap PR #7828 (mai 2026) : shallow prop comparison + opt-in position tracking
|
||||
- Vérifier que chaque NodeView utilise `stopEvent()`, `ignoreMutation()`, et ne déclenche pas de setState inutile
|
||||
|
||||
### 5. Vérification
|
||||
- `console.count('editor render')` dans le composant éditeur pour mesurer le nombre de re-renders
|
||||
- Objectif : 0 re-render React pendant la frappe pure (seul ProseMirror DOM bouge)
|
||||
|
||||
---
|
||||
|
||||
## US-EDITOR-UX — Micro-Interactions de Saisie
|
||||
|
||||
> **Status :** À FAIRE
|
||||
> **Depends on :** US-NEXTGEN-EDITOR (drag handle et menu bloc doivent être en place)
|
||||
> **Source recherche :** Mintlify "22 UX improvements" (mai 2026), BlockNote v0.50, BlockNote v0.49
|
||||
|
||||
**Contexte :**
|
||||
Après les quick wins performance (US-EDITOR-PERF) et le drag handle (US-NEXTGEN-EDITOR), il reste des micro-interactions qui font la différence entre un éditeur "correct" et un éditeur "agréable". Mintlify a listé 22 améliorations UX en mai 2026 — voici les plus pertinentes pour Momento.
|
||||
|
||||
**En tant qu'utilisateur**, je veux que chaque interaction courante (insérer un bloc, déplacer du contenu, transformer un format) soit fluide et intuitive, sans recourir à des raccourcis clavier obscurs.
|
||||
|
||||
### 1. Sélection globale de blocs (Haute priorité)
|
||||
- Shift+clic pour sélectionner plusieurs blocs contigus
|
||||
- Drag de sélection multi-blocs
|
||||
- Actions groupées : supprimer, déplacer, transformer en masse
|
||||
- Visuel : blocs sélectionnés avec fond accent/5, bordure accent/30
|
||||
- **Inspiration :** Mintlify "Global Block Selection" — nettoyage de pages longues sans actions répétées
|
||||
|
||||
### 2. Slash menu redessiné (Haute priorité)
|
||||
- Type-to-search avec catégories visuelles (Texte, Média, Données, Intégré, IA)
|
||||
- Navigation clavier fluide (flèches + Entrée, Esc pour fermer)
|
||||
- Épinglage des 3-5 commandes les plus utilisées en haut
|
||||
- Description courte sous chaque item (ex. "Code" -> "Bloc de code avec coloration syntaxique")
|
||||
- Preview visuel au survol pour les blocs complexes (tableau, database, chart)
|
||||
- **Inspiration :** Mintlify slash menu redesign, BlockNote catégories
|
||||
|
||||
### 3. Placeholders contextuels par type de bloc (Moyenne priorité)
|
||||
- Paragraphe vide : "Tapez / pour insérer un bloc..."
|
||||
- Heading H1 vide : "Titre principal"
|
||||
- Heading H2 vide : "Titre de section"
|
||||
- TaskItem vide : "Ajouter une tâche"
|
||||
- Bloc de code vide : "Code..."
|
||||
- Bullet list vide : "Liste"
|
||||
- **Inspiration :** BlockNote "Helpful placeholders"
|
||||
|
||||
### 4. Collage intelligent étendu (Moyenne priorité)
|
||||
- Coller une URL HTTP(S) -> propose : lien hypertexte / intégration image / intégration vidéo
|
||||
- Coller du code (détecté par caractères spéciaux {}[];) -> propose : bloc de code avec auto-détection langage
|
||||
- Coller une image depuis le presse-papier -> upload direct (déjà en prod, vérifier la fluidité)
|
||||
- Coller du HTML riche -> nettoyage intelligent (conserver structure, supprimer styles inline superflus)
|
||||
- **Inspiration :** US-3 Smart Paste existe pour les blocs connectés — étendre au contenu générique
|
||||
|
||||
### 5. "Turn into" instant (Moyenne priorité)
|
||||
- Raccourci clavier : sélectionner du texte + `Cmd+Shift+H` -> cycle H1 > H2 > H3 > paragraphe
|
||||
- Via le menu bloc (US-2) : transformation instantanée sans flash ni re-render visible
|
||||
- Conserver le contenu et les attributs (gras, liens, etc.) lors de la conversion
|
||||
- **Inspiration :** Mintlify "Turn Blocks Into Anything"
|
||||
|
||||
### 6. Undo/redo visuel discret (Basse priorité)
|
||||
- Toast subtil (2s) : "Action annulée" / "Action rétablie"
|
||||
- Raccourci affiché dans le toast : "Cmd+Z pour annuler, Cmd+Shift+Z pour rétablir"
|
||||
- Ne pas utiliser toast pour les actions normales — seulement undo/redo pour confirmer le feedback
|
||||
|
||||
---
|
||||
|
||||
## US-EDITOR-MOBILE — Expérience Tactile & Toolbar Mobile
|
||||
|
||||
> **Status :** À FAIRE
|
||||
> **Depends on :** US-NEXTGEN-EDITOR (drag handle existant)
|
||||
> **Source recherche :** Notion mobile app, Obsidian mobile, benchmark 2026
|
||||
|
||||
**Contexte :**
|
||||
L'éditeur fonctionne sur mobile mais l'expérience est dégradée : la bubble menu est trop petite pour les doigts, le drag handle est masqué (prévu) mais il n'y a pas d'alternative tactile, et les sélections longues sont douloureuses en contenteditable.
|
||||
|
||||
**En tant qu'utilisateur mobile**, je veux pouvoir éditer mes notes confortablement depuis mon téléphone ou tablette, avec des contrôles adaptés au tactile.
|
||||
|
||||
### 1. Toolbar mobile adaptée
|
||||
- Remplacer la bubble menu desktop par une toolbar fixe en bas d'écran (viewport < 768px)
|
||||
- Boutons 44x44px minimum (Apple HIG)
|
||||
- 8 actions principales : Gras, Italique, Surligner, Lien, Liste, Titre, Code, Plus (menu étendu)
|
||||
- Scroll horizontal si plus d'actions
|
||||
- **Inspiration :** Notion mobile toolbar
|
||||
|
||||
### 2. Menu bloc tactile
|
||||
- Pas de drag handle sur mobile (déjà prévu dans US-1)
|
||||
- Alternative : swipe gauche sur un bloc -> reveal actions (supprimer, dupliquer, transformer)
|
||||
- Ou : tap long sur un bloc -> menu contextuel mobile natif (action sheet iOS / bottom sheet Android)
|
||||
- Bouton "Déplacer" dans le menu -> mode réorganisation avec poignées tactiles
|
||||
|
||||
### 3. Sélection de texte améliorée
|
||||
- Les sélections longues en contenteditable sont frustrantes sur mobile
|
||||
- Ajouter un bouton "Sélectionner tout le bloc" dans le menu bloc tactile
|
||||
- Double-tap sélectionne le mot, triple-tap sélectionne le paragraphe (comportement natif iOS/Android, vérifier que TipTap ne l'écrase pas)
|
||||
|
||||
### 4. Performance mobile
|
||||
- Les NodeViews React lourds (Chart, DatabaseBlock) doivent avoir un fallback léger sur mobile
|
||||
- Désactiver les animations de la bubble menu sur mobile (prefers-reduced-motion)
|
||||
- `immediatelyRender: false` déjà en place — bon pour le premier rendu mobile
|
||||
|
||||
---
|
||||
|
||||
## US-EDITOR-MARKDOWN — Rendu WYSIWYG Markdown Fidèle
|
||||
|
||||
> **Status :** À FAIRE (low priority)
|
||||
> **Depends on :** rien (évaluation Milkdown)
|
||||
> **Source recherche :** Milkdown v7.20, "Human Markdown" extension VSCode, round-trip byte-for-byte
|
||||
|
||||
**Contexte :**
|
||||
Momento stocke les notes en HTML (TipTap). Mais les notes de type `markdown` existent aussi. Le problème classique : éditer en riche et voir le Markdown reformatté intégralement (indentations changées, lignes vides supprimées, `##` convertis en soulignements). Milkdown (11k+ stars, ProseMirror + remark) résout ce problème avec un round-trip byte-for-byte.
|
||||
|
||||
**En tant qu'utilisateur**, je veux que mes fichiers Markdown restent intacts quand je les édite en mode visuel — pas de diff parasite sur chaque modification.
|
||||
|
||||
### 1. Évaluation technique Milkdown
|
||||
- Installer `@milkdown/core` + `@milkdown/preset-commonmark` + `@milkdown/preset-gfm`
|
||||
- Benchmarker le round-trip : ouvrir un .md, éditer un paragraphe, sauvegarder, vérifier que seul ce paragraphe change dans le diff
|
||||
- Comparer avec la solution actuelle (TipTap HTML storage) pour les notes markdown
|
||||
- **Référence :** "Human Markdown" VSCode extension (Milkdown + byte-for-byte tests)
|
||||
|
||||
### 2. Mode d'édition dual (si Milkdown adopté)
|
||||
- Toggle dans la toolbar : mode Visuel (WYSIWYG) / mode Brut (CodeMirror)
|
||||
- Transition en <100ms, même position de scroll
|
||||
- Le mode Visuel affiche le rendu live (titres, listes, tables, images, checkboxes cliquables)
|
||||
- Le mode Brut affiche le Markdown source avec coloration syntaxique
|
||||
- **Inspiration :** "Human Markdown" — Cmd+Shift+V pour basculer
|
||||
|
||||
### 3. Intégration avec les notes existantes
|
||||
- Notes type `richtext` : inchangé (stockage HTML TipTap)
|
||||
- Notes type `markdown` : mode dual avec Milkdown
|
||||
- Détection automatique du type à l'ouverture
|
||||
- Conversion possible : `richtext` -> `markdown` (export) et `markdown` -> `richtext` (import)
|
||||
|
||||
### 4. Support GFM complet
|
||||
- Tables Markdown rendues comme de vrais tableaux éditables
|
||||
- Task lists avec checkboxes cliquables
|
||||
- Footnotes rendues inline
|
||||
- Frontmatter YAML en carte collapsible en haut de document
|
||||
- **Inspiration :** "Human Markdown" — GFM complet, Shiki pour le code
|
||||
|
||||
715
mcp-server/node_modules/.package-lock.json
generated
vendored
715
mcp-server/node_modules/.package-lock.json
generated
vendored
@@ -1,9 +1,26 @@
|
||||
{
|
||||
"name": "memento-mcp-server",
|
||||
"version": "3.1.0",
|
||||
"version": "3.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
|
||||
@@ -16,6 +33,13 @@
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||
@@ -392,6 +416,27 @@
|
||||
"@prisma/debug": "5.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
|
||||
"integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
|
||||
@@ -402,6 +447,119 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.9",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"chai": "^5.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
|
||||
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
|
||||
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
|
||||
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "2.1.9",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
|
||||
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.9",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
|
||||
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^3.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
|
||||
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.1.9",
|
||||
"loupe": "^3.1.2",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -454,6 +612,16 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -514,6 +682,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -543,6 +721,33 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -615,6 +820,16 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -681,6 +896,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
@@ -693,12 +915,61 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
@@ -729,6 +1000,16 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
@@ -1024,6 +1305,23 @@
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1099,6 +1397,25 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -1174,6 +1491,30 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
||||
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
@@ -1183,6 +1524,35 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||
@@ -1264,6 +1634,58 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
|
||||
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.4",
|
||||
"@rollup/rollup-android-arm64": "4.60.4",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.4",
|
||||
"@rollup/rollup-darwin-x64": "4.60.4",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.4",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.4",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.4",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.4",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.4",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.4",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.4",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.4",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.4",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.4",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup/node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@@ -1483,6 +1905,30 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -1492,6 +1938,57 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
|
||||
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
|
||||
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -1548,6 +2045,205 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
"rollup": "^4.20.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"lightningcss": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
|
||||
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.3.7",
|
||||
"es-module-lexer": "^1.5.4",
|
||||
"pathe": "^1.1.2",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
|
||||
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "2.1.9",
|
||||
"@vitest/mocker": "2.1.9",
|
||||
"@vitest/pretty-format": "^2.1.9",
|
||||
"@vitest/runner": "2.1.9",
|
||||
"@vitest/snapshot": "2.1.9",
|
||||
"@vitest/spy": "2.1.9",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"chai": "^5.1.2",
|
||||
"debug": "^4.3.7",
|
||||
"expect-type": "^1.1.0",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2",
|
||||
"std-env": "^3.8.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.1",
|
||||
"tinypool": "^1.0.1",
|
||||
"tinyrainbow": "^1.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-node": "2.1.9",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"@vitest/browser": "2.1.9",
|
||||
"@vitest/ui": "2.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -1563,6 +2259,23 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
||||
@@ -2,12 +2,7 @@ import { cookies } from 'next/headers'
|
||||
import { getAllNotes } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { HomeClient } from '@/components/home-client'
|
||||
import {
|
||||
NOTES_LAYOUT_COOKIE,
|
||||
NOTES_VIEW_TYPE_COOKIE,
|
||||
parseNotesLayoutMode,
|
||||
parseNotesViewType,
|
||||
} from '@/lib/notes-view-preference'
|
||||
import { NOTES_LAYOUT_COOKIE, parseNotesLayoutMode } from '@/lib/notes-view-preference'
|
||||
|
||||
export default async function HomePage() {
|
||||
const [allNotes, settings, cookieStore] = await Promise.all([
|
||||
@@ -17,13 +12,11 @@ export default async function HomePage() {
|
||||
])
|
||||
|
||||
const initialLayoutMode = parseNotesLayoutMode(cookieStore.get(NOTES_LAYOUT_COOKIE)?.value)
|
||||
const initialViewType = parseNotesViewType(cookieStore.get(NOTES_VIEW_TYPE_COOKIE)?.value)
|
||||
|
||||
return (
|
||||
<HomeClient
|
||||
initialNotes={allNotes}
|
||||
initialLayoutMode={initialLayoutMode}
|
||||
initialViewType={initialViewType}
|
||||
initialSettings={{
|
||||
showRecentNotes: settings?.showRecentNotes !== false,
|
||||
noteHistory: settings?.noteHistory === true,
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
function extractBlockContent(html: string, blockId: string): string | null {
|
||||
const regex = new RegExp(
|
||||
`<(?:p|h[1-6]|blockquote)[^>]*data-id="${blockId}"[^>]*>([\\s\\S]*?)<\\/(?:p|h[1-6]|blockquote)>`,
|
||||
'i'
|
||||
)
|
||||
const match = regex.exec(html)
|
||||
if (!match) return null
|
||||
return match[1].replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
import { extractBlockContentById } from '@/lib/blocks/extract-blocks'
|
||||
|
||||
// GET /api/blocks/[blockId]/status?sourceNoteId=xxx
|
||||
export async function GET(
|
||||
@@ -38,7 +29,7 @@ export async function GET(
|
||||
return NextResponse.json({ exists: false, content: '', sourceNoteTitle: '' })
|
||||
}
|
||||
|
||||
const content = extractBlockContent(note.content, blockId)
|
||||
const content = extractBlockContentById(note.content, blockId)
|
||||
|
||||
if (content === null) {
|
||||
return NextResponse.json({ exists: false, content: '', sourceNoteTitle: note.title || '' })
|
||||
|
||||
@@ -1035,106 +1035,59 @@ html.font-system * {
|
||||
/* --- Editor Wrapper --- */
|
||||
.notion-editor-wrapper {
|
||||
position: relative;
|
||||
padding-left: 36px; /* Espace gutter pour la poignée */
|
||||
}
|
||||
|
||||
/* --- Drag Handle Gutter --- */
|
||||
.notion-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 28px; /* Légèrement plus grand pour faciliter la sélection */
|
||||
border-radius: 6px;
|
||||
color: var(--muted-foreground);
|
||||
/* Animation de glissement fluide (top) et d'échelle au clic */
|
||||
transition: opacity 0.2s ease,
|
||||
top 0.12s cubic-bezier(0.25, 1, 0.5, 1),
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.notion-drag-handle:hover {
|
||||
background-color: rgba(59, 130, 246, 0.15); /* blue-500 @ 15% */
|
||||
color: #3b82f6; /* blue-500 */
|
||||
}
|
||||
|
||||
.dark .notion-drag-handle:hover {
|
||||
background-color: rgba(59, 130, 246, 0.25);
|
||||
color: #60a5fa; /* blue-400 */
|
||||
}
|
||||
|
||||
.notion-drag-handle:active {
|
||||
transform: scale(0.9);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.notion-drag-handle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* --- Styles pour l'extension officielle Tiptap DragHandle --- */
|
||||
/* --- Drag Handle (Novel / global-drag-handle) --- */
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease, transform 0.12s ease;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.drag-handle.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.drag-handle[data-dragging="true"] {
|
||||
cursor: grabbing;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Inner content wrapper - positioned in gutter */
|
||||
.drag-handle > .notion-drag-handle {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
min-height: 28px;
|
||||
border-radius: 6px;
|
||||
width: 1.25rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 4px;
|
||||
color: var(--muted-foreground);
|
||||
background: transparent;
|
||||
transition: background-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
opacity: 1;
|
||||
z-index: 50;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.drag-handle.visible > .notion-drag-handle:hover {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
.drag-handle:hover {
|
||||
background-color: rgba(59, 130, 246, 0.12);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.dark .drag-handle.visible > .notion-drag-handle:hover {
|
||||
background-color: rgba(59, 130, 246, 0.25);
|
||||
.dark .drag-handle:hover {
|
||||
background-color: rgba(59, 130, 246, 0.22);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.drag-handle:active > .notion-drag-handle {
|
||||
transform: scale(0.9);
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-handle.hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.drag-handle {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Curseur pendant le drag de bloc */
|
||||
.notion-editor-wrapper .ProseMirror.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Pas de surbrillance node pendant le drag (Novel) */
|
||||
.notion-editor-wrapper .ProseMirror:not(.dragging) .ProseMirror-selectednode,
|
||||
.notion-editor-wrapper .ProseMirror:not(.dragging) .ProseMirror-selectednoderange {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* --- Block Action Menu (glassmorphism dropdown) --- */
|
||||
@@ -1197,6 +1150,30 @@ html.font-system * {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.smart-paste-menu {
|
||||
min-width: 240px;
|
||||
padding: 8px 4px 4px;
|
||||
}
|
||||
|
||||
.smart-paste-menu__hint {
|
||||
margin: 0 12px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.smart-paste-menu__source {
|
||||
margin: 0 12px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.block-action-separator {
|
||||
height: 1px;
|
||||
margin: 4px 8px;
|
||||
@@ -1233,6 +1210,296 @@ html.font-system * {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* --- Inline Database Block (US-NEXTGEN-EDITOR) --- */
|
||||
.database-block-wrapper {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.database-block__inner {
|
||||
border: 1px solid #e8e6e3;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
padding: 1rem 1.1rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.dark .database-block__inner {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(24, 24, 27, 0.5);
|
||||
}
|
||||
|
||||
.database-block__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.database-block__title {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-ink, #1a1a1a);
|
||||
}
|
||||
|
||||
.database-block__id {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
opacity: 0.45;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.database-block__hint {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.55;
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
|
||||
.database-block__view-toggle {
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.dark .database-block__view-toggle {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.database-block__view-toggle button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0.65;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.database-block__view-toggle button.is-active {
|
||||
background: white;
|
||||
opacity: 1;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dark .database-block__view-toggle button.is-active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.database-block__table-wrap {
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.dark .database-block__table-wrap {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.database-block__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.database-block__table th {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.55;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.database-block__table td {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.database-block__works-cell {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.database-block__rollup {
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.database-block__delete-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.45;
|
||||
cursor: pointer;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.database-block__delete-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.database-block__inline-form,
|
||||
.database-block__book-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.database-block__book-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.database-block__form-label,
|
||||
.database-block__form-heading {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.55;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.database-block__input {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: white;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dark .database-block__input {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.database-block__primary-btn,
|
||||
.database-block__submit {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.45rem 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.database-block__primary-btn:hover,
|
||||
.database-block__submit:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.database-block__card {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.dark .database-block__card {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.database-block__card-delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 2;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.database-block__card:hover .database-block__card-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.database-block__card-cover {
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
.database-block__card-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.database-block__card-body {
|
||||
padding: 0.55rem 0.65rem 0.65rem;
|
||||
}
|
||||
|
||||
.database-block__card-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.database-block__tag {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.database-block__tag--author {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.database-block__tag--genre {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.database-block__card-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 140px;
|
||||
border-radius: 10px;
|
||||
border: 2px dashed rgba(0, 0, 0, 0.08);
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* --- Drop Indicator Line --- */
|
||||
.notion-drop-indicator {
|
||||
position: absolute;
|
||||
@@ -1254,17 +1521,30 @@ html.font-system * {
|
||||
}
|
||||
|
||||
/* --- Selected Node Visual Highlight during Drag --- */
|
||||
.notion-editor-wrapper .ProseMirror-selectednode {
|
||||
.notion-editor-wrapper .ProseMirror-selectednode,
|
||||
.notion-editor-wrapper .ProseMirror-selectednoderange {
|
||||
position: relative;
|
||||
outline: none !important;
|
||||
background-color: rgba(59, 130, 246, 0.08) !important;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.4) !important;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35) !important;
|
||||
}
|
||||
|
||||
.dark .notion-editor-wrapper .ProseMirror-selectednode {
|
||||
background-color: rgba(96, 165, 250, 0.15) !important;
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.5) !important;
|
||||
.notion-editor-wrapper .ProseMirror-selectednode::before,
|
||||
.notion-editor-wrapper .ProseMirror-selectednoderange::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.25rem;
|
||||
background-color: rgba(59, 130, 246, 0.08);
|
||||
border-radius: 0.2rem;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.dark .notion-editor-wrapper .ProseMirror-selectednode,
|
||||
.dark .notion-editor-wrapper .ProseMirror-selectednoderange {
|
||||
background-color: rgba(96, 165, 250, 0.12) !important;
|
||||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.45) !important;
|
||||
}
|
||||
|
||||
/* --- Premium Drag Target Line (Dropcursor) --- */
|
||||
@@ -1287,10 +1567,13 @@ html.font-system * {
|
||||
.notion-editor-wrapper .ProseMirror {
|
||||
outline: none;
|
||||
min-height: 120px;
|
||||
padding: 4px 0;
|
||||
/* Gutter à gauche pour la poignée globale (Novel) */
|
||||
padding: 4px 0 4px 3rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.7;
|
||||
caret-color: var(--primary);
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.notion-editor-wrapper .ProseMirror>*:first-child {
|
||||
@@ -1341,18 +1624,21 @@ html.font-system * {
|
||||
/* --- Lists --- */
|
||||
.notion-editor-wrapper .ProseMirror ul {
|
||||
list-style-type: disc;
|
||||
padding-inline-start: 1.5rem;
|
||||
list-style-position: inside;
|
||||
padding-inline-start: 0;
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.notion-editor-wrapper .ProseMirror ol {
|
||||
list-style-type: decimal;
|
||||
padding-inline-start: 1.5rem;
|
||||
list-style-position: inside;
|
||||
padding-inline-start: 0;
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.notion-editor-wrapper .ProseMirror li>p {
|
||||
margin: 0.1em 0;
|
||||
padding-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.notion-editor-wrapper .ProseMirror li>ul,
|
||||
@@ -2262,6 +2548,7 @@ html.font-system * {
|
||||
line-height: 1.875;
|
||||
color: var(--foreground);
|
||||
outline: none;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.fullpage-editor .ProseMirror p {
|
||||
|
||||
225
memento-note/components/block-action-menu.tsx
Normal file
225
memento-note/components/block-action-menu.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
|
||||
import { ensureBlockReferenceId } from '@/lib/editor/block-reference-id'
|
||||
import { rememberBlockReference } from '@/lib/editor/parse-block-reference'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Trash2, Copy, Repeat, Link, ChevronRight,
|
||||
Heading1, Heading2, Heading3, List, ListOrdered,
|
||||
CheckSquare, Quote, CodeXml, Database,
|
||||
} from 'lucide-react'
|
||||
import { replaceBlockWithDatabase } from '@/components/tiptap-database-block-extension'
|
||||
|
||||
interface BlockActionMenuProps {
|
||||
editor: Editor
|
||||
onClose: () => void
|
||||
anchorRect: DOMRect
|
||||
blockPos: number
|
||||
blockNode: PMNode | null
|
||||
noteId?: string
|
||||
sourceNoteTitle?: string
|
||||
onBlockReferenceCopied?: (html: string) => void
|
||||
}
|
||||
|
||||
type TurnIntoType =
|
||||
| 'heading1' | 'heading2' | 'heading3'
|
||||
| 'bulletList' | 'orderedList' | 'taskList'
|
||||
| 'blockquote' | 'codeBlock' | 'database'
|
||||
|
||||
interface TurnIntoOption {
|
||||
id: TurnIntoType
|
||||
icon: typeof Heading1
|
||||
command?: (editor: Editor) => void
|
||||
isDatabase?: boolean
|
||||
}
|
||||
|
||||
const TURN_INTO_OPTIONS: TurnIntoOption[] = [
|
||||
{ id: 'heading1', icon: Heading1, command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||
{ id: 'heading2', icon: Heading2, command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ id: 'heading3', icon: Heading3, command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
{ id: 'bulletList', icon: List, command: (e) => e.chain().focus().toggleBulletList().run() },
|
||||
{ id: 'orderedList', icon: ListOrdered, command: (e) => e.chain().focus().toggleOrderedList().run() },
|
||||
{ id: 'taskList', icon: CheckSquare, command: (e) => e.chain().focus().toggleTaskList().run() },
|
||||
{ id: 'blockquote', icon: Quote, command: (e) => e.chain().focus().toggleBlockquote().run() },
|
||||
{ id: 'codeBlock', icon: CodeXml, command: (e) => e.chain().focus().toggleCodeBlock().run() },
|
||||
{ id: 'database', icon: Database, isDatabase: true },
|
||||
]
|
||||
|
||||
function focusBlock(editor: Editor, blockPos: number) {
|
||||
const docSize = editor.state.doc.content.size
|
||||
const cursorPos = Math.min(blockPos + 1, docSize)
|
||||
editor.chain().focus().setTextSelection(cursorPos).run()
|
||||
}
|
||||
|
||||
function getBlockPlainContent(editor: Editor, blockPos: number, blockNode: PMNode | null): string {
|
||||
const node = blockNode ?? (blockPos >= 0 ? editor.state.doc.nodeAt(blockPos) : null)
|
||||
if (!node || blockPos < 0) return ''
|
||||
const from = blockPos + 1
|
||||
const to = blockPos + node.nodeSize - 1
|
||||
if (to > from) {
|
||||
return editor.state.doc.textBetween(from, to, '\n', '\0').trim()
|
||||
}
|
||||
return node.textContent?.trim() ?? ''
|
||||
}
|
||||
|
||||
export function BlockActionMenu({
|
||||
editor,
|
||||
onClose,
|
||||
anchorRect,
|
||||
blockPos,
|
||||
blockNode,
|
||||
noteId,
|
||||
sourceNoteTitle,
|
||||
onBlockReferenceCopied,
|
||||
}: BlockActionMenuProps) {
|
||||
const { t } = useLanguage()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [showTurnInto, setShowTurnInto] = useState(false)
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (blockNode && blockPos >= 0) {
|
||||
editor.chain().focus().deleteRange({ from: blockPos, to: blockPos + blockNode.nodeSize }).run()
|
||||
}
|
||||
onClose()
|
||||
}, [editor, blockNode, blockPos, onClose])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (blockNode && blockPos >= 0) {
|
||||
const insertPos = blockPos + blockNode.nodeSize
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.insert(insertPos, blockNode.copy())
|
||||
)
|
||||
editor.commands.focus()
|
||||
}
|
||||
onClose()
|
||||
}, [editor, blockNode, blockPos, onClose])
|
||||
|
||||
const handleCopyRef = useCallback(async () => {
|
||||
if (!noteId?.trim()) {
|
||||
toast.error(t('blockAction.copyRefNoNote'))
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
const blockId = ensureBlockReferenceId(editor, blockPos, blockNode)
|
||||
if (!blockId) {
|
||||
toast.error(t('blockAction.copyRefUnsupported'))
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
const html = editor.getHTML()
|
||||
const blockContent = getBlockPlainContent(editor, blockPos, blockNode)
|
||||
onBlockReferenceCopied?.(html)
|
||||
|
||||
const ref = `${window.location.origin}/home?openNote=${encodeURIComponent(noteId)}#block-${encodeURIComponent(blockId)}`
|
||||
const copied = await copyTextToClipboard(ref)
|
||||
if (copied) {
|
||||
rememberBlockReference(ref, { blockContent, sourceNoteTitle })
|
||||
toast.success(t('blockAction.copied'))
|
||||
} else {
|
||||
toast.error(t('blockAction.copyRefFailed'))
|
||||
}
|
||||
onClose()
|
||||
}, [blockNode, blockPos, editor, noteId, onBlockReferenceCopied, onClose, sourceNoteTitle, t])
|
||||
|
||||
const handleTurnInto = useCallback((option: TurnIntoOption) => {
|
||||
if (blockPos >= 0 && blockNode) {
|
||||
if (option.isDatabase) {
|
||||
replaceBlockWithDatabase(editor, blockPos, blockNode)
|
||||
} else if (option.command) {
|
||||
focusBlock(editor, blockPos)
|
||||
option.command(editor)
|
||||
}
|
||||
}
|
||||
setShowTurnInto(false)
|
||||
onClose()
|
||||
}, [editor, blockNode, blockPos, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const menuStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: anchorRect.right + 6,
|
||||
top: anchorRect.top - 4,
|
||||
zIndex: 9999,
|
||||
}
|
||||
|
||||
if (Number(menuStyle.left) > window.innerWidth - 220) {
|
||||
menuStyle.left = anchorRect.left - 210
|
||||
}
|
||||
if (Number(menuStyle.top) + 300 > window.innerHeight) {
|
||||
menuStyle.top = window.innerHeight - 310
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div ref={menuRef} style={menuStyle} className="block-action-menu">
|
||||
<button type="button" className="block-action-item" onClick={handleDelete}>
|
||||
<Trash2 size={16} />
|
||||
<span>{t('blockAction.delete')}</span>
|
||||
</button>
|
||||
<button type="button" className="block-action-item" onClick={handleDuplicate}>
|
||||
<Copy size={16} />
|
||||
<span>{t('blockAction.duplicate')}</span>
|
||||
</button>
|
||||
|
||||
<div className="block-action-separator" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="block-action-item block-action-submenu-trigger"
|
||||
onClick={() => setShowTurnInto(!showTurnInto)}
|
||||
onMouseEnter={() => setShowTurnInto(true)}
|
||||
>
|
||||
<Repeat size={16} />
|
||||
<span>{t('blockAction.turnInto')}</span>
|
||||
<ChevronRight size={14} className="ml-auto" />
|
||||
</button>
|
||||
|
||||
{showTurnInto && (
|
||||
<div className="block-action-submenu">
|
||||
{TURN_INTO_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
className="block-action-item"
|
||||
onClick={() => handleTurnInto(opt)}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
<span>{t(`blockAction.turnInto_${opt.id}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="block-action-separator" />
|
||||
|
||||
<button type="button" className="block-action-item" onClick={() => { void handleCopyRef() }}>
|
||||
<Link size={16} />
|
||||
<span>{t('blockAction.copyRef')}</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
255
memento-note/components/database-block-editor.tsx
Normal file
255
memento-note/components/database-block-editor.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import {
|
||||
type DatabaseBlockData,
|
||||
randomDefaultCover,
|
||||
} from '@/lib/editor/database-block-types'
|
||||
|
||||
interface DatabaseBlockEditorProps {
|
||||
data: DatabaseBlockData
|
||||
readOnly?: boolean
|
||||
onChange: (data: DatabaseBlockData) => void
|
||||
}
|
||||
|
||||
export function DatabaseBlockEditor({ data, readOnly, onChange }: DatabaseBlockEditorProps) {
|
||||
const { t } = useLanguage()
|
||||
const { dbId, dbView, dbAuthors, dbBooks } = data
|
||||
|
||||
const [newBookTitle, setNewBookTitle] = useState('')
|
||||
const [newBookAuthor, setNewBookAuthor] = useState('')
|
||||
const [newBookTag, setNewBookTag] = useState('')
|
||||
const [newBookCover, setNewBookCover] = useState('')
|
||||
const [newAuthorName, setNewAuthorName] = useState('')
|
||||
|
||||
const patch = useCallback((partial: Partial<DatabaseBlockData>) => {
|
||||
onChange({ ...data, ...partial })
|
||||
}, [data, onChange])
|
||||
|
||||
const handleAddAuthor = useCallback(() => {
|
||||
const name = newAuthorName.trim()
|
||||
if (!name) return
|
||||
if (dbAuthors.some((a) => a.name.toLowerCase() === name.toLowerCase())) return
|
||||
patch({
|
||||
dbAuthors: [...dbAuthors, { id: `auth-${Date.now()}`, name }],
|
||||
})
|
||||
setNewAuthorName('')
|
||||
}, [dbAuthors, newAuthorName, patch])
|
||||
|
||||
const handleAddBook = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const title = newBookTitle.trim()
|
||||
let author = newBookAuthor.trim()
|
||||
if (!title || !author) return
|
||||
|
||||
let nextAuthors = [...dbAuthors]
|
||||
if (author === '__new__') return
|
||||
if (!nextAuthors.some((a) => a.name.toLowerCase() === author.toLowerCase())) {
|
||||
nextAuthors = [...nextAuthors, { id: `auth-${Date.now()}`, name: author }]
|
||||
}
|
||||
|
||||
patch({
|
||||
dbAuthors: nextAuthors,
|
||||
dbBooks: [
|
||||
...dbBooks,
|
||||
{
|
||||
id: `bk-${Date.now()}`,
|
||||
title,
|
||||
author,
|
||||
cover: newBookCover.trim() || randomDefaultCover(),
|
||||
tag: newBookTag.trim() || t('databaseBlock.defaultTag'),
|
||||
},
|
||||
],
|
||||
})
|
||||
setNewBookTitle('')
|
||||
setNewBookAuthor('')
|
||||
setNewBookTag('')
|
||||
setNewBookCover('')
|
||||
}, [dbAuthors, dbBooks, newBookAuthor, newBookCover, newBookTag, newBookTitle, patch, t])
|
||||
|
||||
const handleDeleteBook = useCallback((id: string) => {
|
||||
patch({ dbBooks: dbBooks.filter((b) => b.id !== id) })
|
||||
}, [dbBooks, patch])
|
||||
|
||||
const handleDeleteAuthor = useCallback((id: string) => {
|
||||
patch({ dbAuthors: dbAuthors.filter((a) => a.id !== id) })
|
||||
}, [dbAuthors, patch])
|
||||
|
||||
return (
|
||||
<div className="database-block not-prose my-4 text-left">
|
||||
<div className="database-block__inner">
|
||||
<div className="database-block__header">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-lg shrink-0" aria-hidden>📚</span>
|
||||
<div className="min-w-0">
|
||||
<span className="database-block__title">{t('databaseBlock.title')}</span>
|
||||
<span className="database-block__id">id: {dbId}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="database-block__view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patch({ dbView: 'table' })}
|
||||
className={dbView === 'table' ? 'is-active' : ''}
|
||||
>
|
||||
{t('databaseBlock.viewTable')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => patch({ dbView: 'card' })}
|
||||
className={dbView === 'card' ? 'is-active' : ''}
|
||||
>
|
||||
{t('databaseBlock.viewCards')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="database-block__hint">{t('databaseBlock.hint')}</p>
|
||||
|
||||
{dbView === 'table' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="database-block__table-wrap">
|
||||
<table className="database-block__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('databaseBlock.colAuthor')}</th>
|
||||
<th>{t('databaseBlock.colWorks')}</th>
|
||||
<th className="text-right">{t('databaseBlock.colRollup')}</th>
|
||||
{!readOnly && <th className="w-12" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dbAuthors.map((auth) => {
|
||||
const authorBooks = dbBooks.filter(
|
||||
(b) => b.author.toLowerCase() === auth.name.toLowerCase(),
|
||||
)
|
||||
const worksStr = authorBooks.map((b) => b.title).join(', ')
|
||||
|| t('databaseBlock.noLinkedWorks')
|
||||
return (
|
||||
<tr key={auth.id}>
|
||||
<td className="font-semibold">{auth.name}</td>
|
||||
<td className="database-block__works-cell" title={worksStr}>{worksStr}</td>
|
||||
<td className="database-block__rollup">{authorBooks.length}</td>
|
||||
{!readOnly && (
|
||||
<td className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteAuthor(auth.id)}
|
||||
className="database-block__delete-btn"
|
||||
>
|
||||
{t('databaseBlock.deleteShort')}
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="database-block__inline-form">
|
||||
<span className="database-block__form-label">{t('databaseBlock.addAuthor')}</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('databaseBlock.authorPlaceholder')}
|
||||
value={newAuthorName}
|
||||
onChange={(e) => setNewAuthorName(e.target.value)}
|
||||
className="database-block__input flex-1"
|
||||
/>
|
||||
<button type="button" onClick={handleAddAuthor} className="database-block__primary-btn">
|
||||
{t('databaseBlock.createAuthor')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{dbBooks.map((book) => (
|
||||
<div key={book.id} className="database-block__card group/book">
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteBook(book.id)}
|
||||
className="database-block__card-delete"
|
||||
title={t('databaseBlock.deleteCard')}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
<div className="database-block__card-cover">
|
||||
<img src={book.cover} alt={book.title} referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
<div className="database-block__card-body">
|
||||
<p className="database-block__card-title" title={book.title}>{book.title}</p>
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
<span className="database-block__tag database-block__tag--author">{book.author}</span>
|
||||
<span className="database-block__tag database-block__tag--genre">{book.tag}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="database-block__card-placeholder">
|
||||
<span className="text-xl mb-1" aria-hidden>📖</span>
|
||||
<span className="font-bold text-[10px] uppercase tracking-widest">{t('databaseBlock.worksBase')}</span>
|
||||
<span className="text-[8.5px] text-zinc-400 mt-1">
|
||||
{t('databaseBlock.storedCount', { count: dbBooks.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<form onSubmit={handleAddBook} className="database-block__book-form">
|
||||
<span className="database-block__form-heading">{t('databaseBlock.addWork')}</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('databaseBlock.bookTitlePlaceholder')}
|
||||
value={newBookTitle}
|
||||
onChange={(e) => setNewBookTitle(e.target.value)}
|
||||
className="database-block__input"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={newBookAuthor}
|
||||
onChange={(e) => setNewBookAuthor(e.target.value)}
|
||||
className="database-block__input"
|
||||
required
|
||||
>
|
||||
<option value="">{t('databaseBlock.selectAuthor')}</option>
|
||||
{dbAuthors.map((auth) => (
|
||||
<option key={auth.id} value={auth.name}>{auth.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('databaseBlock.tagPlaceholder')}
|
||||
value={newBookTag}
|
||||
onChange={(e) => setNewBookTag(e.target.value)}
|
||||
className="database-block__input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('databaseBlock.coverPlaceholder')}
|
||||
value={newBookCover}
|
||||
onChange={(e) => setNewBookCover(e.target.value)}
|
||||
className="database-block__input"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="database-block__submit">
|
||||
{t('databaseBlock.insertWork')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
memento-note/components/editor-block-drag-handle.tsx
Normal file
78
memento-note/components/editor-block-drag-handle.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Poignée drag globale (Novel / tiptap-extension-global-drag-handle).
|
||||
* L’extension TipTap positionne cet élément ; le clic ouvre le menu bloc.
|
||||
*/
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { BLOCK_DRAG_HANDLE_ID, resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
|
||||
|
||||
type EditorBlockDragHandleProps = {
|
||||
editor: Editor | null
|
||||
onOpenMenu: (anchorRect: DOMRect) => void
|
||||
}
|
||||
|
||||
export const EditorBlockDragHandle = memo(function EditorBlockDragHandle({
|
||||
editor,
|
||||
onOpenMenu,
|
||||
}: EditorBlockDragHandleProps) {
|
||||
const dragStartedRef = useRef(false)
|
||||
const onOpenMenuRef = useRef(onOpenMenu)
|
||||
onOpenMenuRef.current = onOpenMenu
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || editor.isDestroyed) return
|
||||
const el = document.getElementById(BLOCK_DRAG_HANDLE_ID)
|
||||
if (!el) return
|
||||
|
||||
const onPointerDown = () => {
|
||||
dragStartedRef.current = false
|
||||
}
|
||||
|
||||
const onDragStart = () => {
|
||||
dragStartedRef.current = true
|
||||
}
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (dragStartedRef.current) {
|
||||
dragStartedRef.current = false
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const block = resolveBlockAtDragHandle(editor)
|
||||
if (!block) return
|
||||
onOpenMenuRef.current(el.getBoundingClientRect())
|
||||
}
|
||||
|
||||
el.addEventListener('pointerdown', onPointerDown)
|
||||
el.addEventListener('dragstart', onDragStart)
|
||||
el.addEventListener('click', onClick)
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('pointerdown', onPointerDown)
|
||||
el.removeEventListener('dragstart', onDragStart)
|
||||
el.removeEventListener('click', onClick)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<div
|
||||
id={BLOCK_DRAG_HANDLE_ID}
|
||||
className="drag-handle hide"
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="4" cy="3" r="1.2" />
|
||||
<circle cx="10" cy="3" r="1.2" />
|
||||
<circle cx="4" cy="7" r="1.2" />
|
||||
<circle cx="10" cy="7" r="1.2" />
|
||||
<circle cx="4" cy="11" r="1.2" />
|
||||
<circle cx="10" cy="11" r="1.2" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -5,14 +5,11 @@ import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Note } from '@/lib/types'
|
||||
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes'
|
||||
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, type NotesViewType, isClassicLayoutMode } from '@/components/notes-list-views'
|
||||
import { NotesListViews, type NotesLayoutMode, type NotesClassicLayoutMode, isClassicLayoutMode } from '@/components/notes-list-views'
|
||||
import {
|
||||
NOTES_LAYOUT_STORAGE_KEY,
|
||||
NOTES_VIEW_TYPE_STORAGE_KEY,
|
||||
parseNotesLayoutMode,
|
||||
parseNotesViewType,
|
||||
setNotesLayoutPreference,
|
||||
setNotesViewTypePreference,
|
||||
} from '@/lib/notes-view-preference'
|
||||
import { useNotebookSchema } from '@/hooks/use-notebook-schema'
|
||||
import {
|
||||
@@ -87,14 +84,12 @@ interface HomeClientProps {
|
||||
initialNotes: Note[]
|
||||
initialSettings: InitialSettings
|
||||
initialLayoutMode?: NotesLayoutMode
|
||||
initialViewType?: NotesViewType
|
||||
}
|
||||
|
||||
export function HomeClient({
|
||||
initialNotes,
|
||||
initialSettings,
|
||||
initialLayoutMode = 'list',
|
||||
initialViewType = 'notes',
|
||||
}: HomeClientProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
@@ -136,7 +131,6 @@ export function HomeClient({
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
|
||||
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState('')
|
||||
const [viewType, setViewType] = useState<NotesViewType>(initialViewType)
|
||||
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
|
||||
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
|
||||
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
||||
@@ -164,20 +158,11 @@ export function HomeClient({
|
||||
|
||||
useEffect(() => {
|
||||
const storedLayout = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
|
||||
const storedViewType = parseNotesViewType(localStorage.getItem(NOTES_VIEW_TYPE_STORAGE_KEY))
|
||||
if (storedLayout !== initialLayoutMode) {
|
||||
setLayoutMode(storedLayout)
|
||||
setNotesLayoutPreference(storedLayout)
|
||||
}
|
||||
if (storedViewType !== initialViewType) {
|
||||
setViewType(storedViewType)
|
||||
setNotesViewTypePreference(storedViewType)
|
||||
}
|
||||
}, [initialLayoutMode, initialViewType])
|
||||
|
||||
useEffect(() => {
|
||||
setNotesViewTypePreference(viewType)
|
||||
}, [viewType])
|
||||
}, [initialLayoutMode])
|
||||
|
||||
useEffect(() => {
|
||||
setNotesLayoutPreference(layoutMode)
|
||||
@@ -768,10 +753,12 @@ export function HomeClient({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-h-0 flex-1 flex-col gap-3 py-1'
|
||||
'flex w-full min-h-0 flex-1 flex-col',
|
||||
editingNote ? 'h-full overflow-hidden' : 'gap-3 py-1'
|
||||
)}
|
||||
>
|
||||
{editingNote ? (
|
||||
<div className="flex flex-1 min-h-0 h-full w-full overflow-hidden">
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
@@ -779,6 +766,7 @@ export function HomeClient({
|
||||
onNoteSaved={handleNoteSaved}
|
||||
fullPage
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
|
||||
<div
|
||||
@@ -941,34 +929,6 @@ export function HomeClient({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewType('notes')}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
|
||||
viewType === 'notes'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t('notes.viewNotes')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewType('tasks')}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
|
||||
viewType === 'tasks'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t('notes.viewTasks')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewType === 'notes' && (
|
||||
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1043,9 +1003,8 @@ export function HomeClient({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewType === 'notes' && currentNotebook && structuredModeActive && (
|
||||
{currentNotebook && structuredModeActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddPropertyOpen(true)}
|
||||
@@ -1207,7 +1166,6 @@ export function HomeClient({
|
||||
<NotesListViews
|
||||
notes={sortedNotes}
|
||||
pinnedNotes={sortedPinnedNotes}
|
||||
viewType={viewType}
|
||||
layoutMode={classicLayoutMode}
|
||||
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
|
||||
import { NoteEditorProvider } from './note-editor-context'
|
||||
import { NoteEditorFullPage } from './note-editor-full-page'
|
||||
import { NoteEditorDialog } from './note-editor-dialog'
|
||||
import { NoteEditorPeekHost } from './note-editor-peek-host'
|
||||
import { Note } from '@/lib/types'
|
||||
|
||||
interface NoteEditorProps {
|
||||
@@ -16,11 +17,13 @@ interface NoteEditorProps {
|
||||
export function NoteEditor({ note, readOnly, onClose, fullPage = false, onNoteSaved }: NoteEditorProps) {
|
||||
return (
|
||||
<NoteEditorProvider note={note} readOnly={readOnly} fullPage={fullPage} onNoteSaved={onNoteSaved}>
|
||||
<NoteEditorPeekHost noteId={note.id} fullPage={fullPage}>
|
||||
{fullPage ? (
|
||||
<NoteEditorFullPage onClose={onClose} />
|
||||
) : (
|
||||
<NoteEditorDialog onClose={onClose} />
|
||||
)}
|
||||
</NoteEditorPeekHost>
|
||||
</NoteEditorProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ export function NoteContentArea() {
|
||||
className="min-h-[280px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
noteTitle={state.title || note.title}
|
||||
sourceUrl={note.sourceUrl}
|
||||
/>
|
||||
</div>
|
||||
@@ -122,6 +123,7 @@ export function NoteContentArea() {
|
||||
className="min-h-[200px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
noteTitle={state.title || note.title}
|
||||
sourceUrl={note.sourceUrl}
|
||||
/>
|
||||
<GhostTags
|
||||
|
||||
@@ -25,14 +25,12 @@ import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { useState } from 'react'
|
||||
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
|
||||
import { MemoryEchoSection } from '@/components/memory-echo-section'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface NoteEditorFullPageProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
@@ -66,10 +64,9 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
return (
|
||||
<>
|
||||
{/* ── outer container ── */}
|
||||
<div className="h-screen flex items-stretch overflow-hidden transition-all duration-500">
|
||||
|
||||
<div className="flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden">
|
||||
{/* ── main scrollable column ── */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background">
|
||||
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background min-w-0">
|
||||
|
||||
{/* TOOLBAR */}
|
||||
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import type { Note } from '@/lib/types'
|
||||
import { getNoteById } from '@/app/actions/notes'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import {
|
||||
NOTE_PEEK_OPEN_EVENT,
|
||||
NOTE_PEEK_CLOSE_EVENT,
|
||||
type NotePeekOpenDetail,
|
||||
} from '@/lib/note-peek-sync'
|
||||
import { NoteEditorSplitPeek } from './note-editor-split-peek'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface NoteEditorPeekHostProps {
|
||||
noteId: string
|
||||
fullPage?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function NoteEditorPeekHost({ noteId, fullPage, children }: NoteEditorPeekHostProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
const [peekState, setPeekState] = useState<{ note: Note; blockId?: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const onOpenPeek = (event: Event) => {
|
||||
const detail = (event as CustomEvent<NotePeekOpenDetail>).detail
|
||||
if (!detail?.noteId) return
|
||||
if (detail.noteId === noteId) return
|
||||
|
||||
void getNoteById(detail.noteId).then((fetched) => {
|
||||
if (fetched) {
|
||||
setPeekState({ note: fetched, blockId: detail.blockId })
|
||||
} else {
|
||||
toast.error(t('notePeek.loadFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
const onClosePeek = () => setPeekState(null)
|
||||
|
||||
window.addEventListener(NOTE_PEEK_OPEN_EVENT, onOpenPeek)
|
||||
window.addEventListener(NOTE_PEEK_CLOSE_EVENT, onClosePeek)
|
||||
return () => {
|
||||
window.removeEventListener(NOTE_PEEK_OPEN_EVENT, onOpenPeek)
|
||||
window.removeEventListener(NOTE_PEEK_CLOSE_EVENT, onClosePeek)
|
||||
}
|
||||
}, [noteId, t])
|
||||
|
||||
const handleClosePeek = useCallback(() => {
|
||||
setPeekState(null)
|
||||
}, [])
|
||||
|
||||
const handleOpenPeekFully = useCallback(() => {
|
||||
if (!peekState) return
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId, reason: 'before-peek-full-open' },
|
||||
}))
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('openNote', peekState.note.id)
|
||||
router.replace(params.toString() ? `/home?${params.toString()}` : '/home', { scroll: false })
|
||||
setPeekState(null)
|
||||
}, [noteId, peekState, router, searchParams])
|
||||
|
||||
const shellClass = fullPage
|
||||
? 'flex flex-1 min-h-0 h-full w-full items-stretch overflow-hidden'
|
||||
: 'relative flex min-h-0 flex-1 flex-col overflow-hidden'
|
||||
|
||||
return (
|
||||
<div className={`${shellClass}${peekState ? (isRtl ? ' flex-row-reverse' : ' flex-row') : ''}`}>
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">{children}</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{peekState && (
|
||||
<NoteEditorSplitPeek
|
||||
key={peekState.note.id}
|
||||
note={peekState.note}
|
||||
blockId={peekState.blockId}
|
||||
onClose={handleClosePeek}
|
||||
onOpenFully={handleOpenPeekFully}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
memento-note/components/note-editor/note-editor-split-peek.tsx
Normal file
104
memento-note/components/note-editor/note-editor-split-peek.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { X, Maximize2 } from 'lucide-react'
|
||||
import type { Note } from '@/lib/types'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { NoteEditorProvider, useNoteEditorContext } from './note-editor-context'
|
||||
import { NoteTitleBlock } from './note-title-block'
|
||||
import { NoteContentArea } from './note-content-area'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
|
||||
interface NoteEditorSplitPeekProps {
|
||||
note: Note
|
||||
blockId?: string
|
||||
onClose: () => void
|
||||
onOpenFully: () => void
|
||||
}
|
||||
|
||||
function PeekEditorBody({ blockId }: { blockId?: string }) {
|
||||
const { note } = useNoteEditorContext()
|
||||
const { t, language } = useLanguage()
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
const scrollRootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!blockId) return
|
||||
const timer = window.setTimeout(() => {
|
||||
const escaped = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(blockId) : blockId
|
||||
const el = scrollRootRef.current?.querySelector(`[data-id="${escaped}"]`)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 450)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [blockId, note.id])
|
||||
|
||||
return (
|
||||
<div ref={scrollRootRef} className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto w-full px-6 sm:px-8 py-10 space-y-8 pb-24">
|
||||
<p
|
||||
className="text-[10px] uppercase tracking-[.25em] font-bold text-[var(--color-concrete)]"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{formatAbsoluteDateLocalized(new Date(note.contentUpdatedAt), language, 'MMM d, yyyy', dateLocale)}
|
||||
</p>
|
||||
<NoteTitleBlock />
|
||||
<div className="max-w-xl mx-auto w-full">
|
||||
<NoteContentArea />
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--color-concrete)] italic">{t('notePeek.readOnlyHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteEditorSplitPeek({ note, blockId, onClose, onOpenFully }: NoteEditorSplitPeekProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 'min(50vw, 720px)', opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 340, damping: 34 }}
|
||||
className={`shrink-0 h-full min-h-0 bg-[#fafaf9] dark:bg-zinc-950 flex flex-col overflow-hidden z-40 ${
|
||||
isRtl
|
||||
? 'border-r border-black/10 dark:border-white/10 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.12)]'
|
||||
: 'border-l border-black/10 dark:border-white/10 shadow-[-4px_0_24px_-12px_rgba(0,0,0,0.12)]'
|
||||
}`}
|
||||
aria-label={t('notePeek.panelLabel')}
|
||||
>
|
||||
<NoteEditorProvider note={note} readOnly fullPage>
|
||||
<div className="shrink-0 px-4 py-2.5 flex items-center justify-between gap-3 border-b border-black/[0.06] dark:border-white/[0.06] bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm">
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-[var(--color-concrete)] truncate">
|
||||
{t('notePeek.label')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenFully}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-blue-600 dark:text-blue-400 hover:bg-blue-500/10 transition-colors"
|
||||
title={t('notePeek.openFullyHelp')}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
{t('notePeek.openFully')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-[var(--color-concrete)] hover:text-[var(--color-ink)] hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
title={t('notePeek.close')}
|
||||
aria-label={t('notePeek.close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PeekEditorBody blockId={blockId} />
|
||||
</NoteEditorProvider>
|
||||
</motion.aside>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useHydrated } from '@/lib/use-hydrated'
|
||||
|
||||
type NotesEditorialViewProps = {
|
||||
notes: Note[]
|
||||
@@ -364,6 +365,7 @@ export function NotesEditorialView({
|
||||
const { t, language } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
const { data: allLabels } = useLabelsQuery()
|
||||
const hydrated = useHydrated()
|
||||
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -388,9 +390,9 @@ export function NotesEditorialView({
|
||||
return (
|
||||
<motion.article
|
||||
key={note.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={hydrated ? { opacity: 0, y: 20 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.05 * index, duration: 0.6 }}
|
||||
transition={hydrated ? { delay: 0.05 * index, duration: 0.6 } : { duration: 0 }}
|
||||
className="space-y-4 group cursor-pointer relative pb-8"
|
||||
onClick={() => onOpen(note)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useTransition, useEffect, useCallback } from 'react'
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
@@ -28,7 +28,6 @@ import { getNoteDisplayTitle, getNoteFeedImage, getNotePlainExcerpt, prepareNote
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useLabelsQuery } from '@/lib/query-hooks'
|
||||
import { updateNote } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
@@ -39,8 +38,6 @@ import { toast } from 'sonner'
|
||||
import {
|
||||
Pin,
|
||||
FileText,
|
||||
Link2,
|
||||
CheckSquare,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Wind,
|
||||
@@ -50,6 +47,7 @@ import {
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useHydrated } from '@/lib/use-hydrated'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
@@ -60,52 +58,6 @@ export type NotesClassicLayoutMode = 'grid' | 'list' | 'table'
|
||||
export function isClassicLayoutMode(mode: NotesLayoutMode): mode is NotesClassicLayoutMode {
|
||||
return mode === 'grid' || mode === 'list' || mode === 'table'
|
||||
}
|
||||
export type NotesViewType = 'notes' | 'tasks'
|
||||
|
||||
type TaskItem = {
|
||||
id: string
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
text: string
|
||||
completed: boolean
|
||||
lineIndex: number
|
||||
}
|
||||
|
||||
function getNoteTasksStats(content: string) {
|
||||
const lines = (content || '').split('\n')
|
||||
let total = 0
|
||||
let completed = 0
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
|
||||
if (match) {
|
||||
total++
|
||||
if (match[1].toLowerCase() === 'x') completed++
|
||||
}
|
||||
}
|
||||
return { completed, total }
|
||||
}
|
||||
|
||||
function extractTasksFromNotes(notes: Note[]): TaskItem[] {
|
||||
const tasks: TaskItem[] = []
|
||||
for (const note of notes) {
|
||||
const title = note.title?.trim() || 'Sans titre'
|
||||
const lines = (note.content || '').split('\n')
|
||||
lines.forEach((line, idx) => {
|
||||
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
|
||||
if (match) {
|
||||
tasks.push({
|
||||
id: `${note.id}-${idx}`,
|
||||
noteId: note.id,
|
||||
noteTitle: title,
|
||||
text: match[2].trim(),
|
||||
completed: match[1].toLowerCase() === 'x',
|
||||
lineIndex: idx,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
function getNotebookColor(notebookId: string | null | undefined, name?: string) {
|
||||
const colors = [
|
||||
@@ -247,7 +199,6 @@ export type { NoteCollectionActions } from '@/lib/note-change-sync'
|
||||
type NotesListViewsProps = {
|
||||
notes: Note[]
|
||||
pinnedNotes?: Note[]
|
||||
viewType: NotesViewType
|
||||
layoutMode: NotesLayoutMode
|
||||
onOpen: (note: Note, readOnly?: boolean) => void
|
||||
onOpenHistory?: (note: Note) => void
|
||||
@@ -258,7 +209,6 @@ type NotesListViewsProps = {
|
||||
export function NotesListViews({
|
||||
notes,
|
||||
pinnedNotes = [],
|
||||
viewType,
|
||||
layoutMode,
|
||||
onOpen,
|
||||
onOpenHistory,
|
||||
@@ -275,8 +225,7 @@ export function NotesListViews({
|
||||
const { data: session } = useSession()
|
||||
const { notebooks } = useNotebooks()
|
||||
const { data: allLabels = [] } = useLabelsQuery()
|
||||
const [, startTransition] = useTransition()
|
||||
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'tasks' | 'modified' | null>(null)
|
||||
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'modified' | null>(null)
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null)
|
||||
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
|
||||
|
||||
@@ -298,24 +247,7 @@ export function NotesListViews({
|
||||
return [...pinnedNotes, ...unpinned]
|
||||
}, [notes, pinnedNotes])
|
||||
|
||||
const extractTasks = useMemo(() => extractTasksFromNotes(allDisplayNotes), [allDisplayNotes])
|
||||
const completedTasksCount = extractTasks.filter((task) => task.completed).length
|
||||
|
||||
const handleToggleTask = (task: TaskItem) => {
|
||||
const note = allDisplayNotes.find((n) => n.id === task.noteId)
|
||||
if (!note) return
|
||||
const lines = (note.content || '').split('\n')
|
||||
const line = lines[task.lineIndex]
|
||||
if (!line) return
|
||||
const nextChar = task.completed ? ' ' : 'x'
|
||||
lines[task.lineIndex] = line.replace(/\[([ xX])\]/, `[${nextChar}]`)
|
||||
startTransition(async () => {
|
||||
await updateNote(note.id, { content: lines.join('\n') }, { skipRevalidation: true })
|
||||
onNotePatch?.(note.id, { content: lines.join('\n') })
|
||||
})
|
||||
}
|
||||
|
||||
const handleSort = (field: 'title' | 'notebook' | 'tasks' | 'modified') => {
|
||||
const handleSort = (field: 'title' | 'notebook' | 'modified') => {
|
||||
if (sortColumn !== field) {
|
||||
setSortColumn(field)
|
||||
setSortDirection('asc')
|
||||
@@ -339,9 +271,6 @@ export function NotesListViews({
|
||||
} else if (sortColumn === 'notebook') {
|
||||
valA = notebooks.find((nb) => nb.id === a.notebookId)?.name?.toLowerCase() || ''
|
||||
valB = notebooks.find((nb) => nb.id === b.notebookId)?.name?.toLowerCase() || ''
|
||||
} else if (sortColumn === 'tasks') {
|
||||
valA = getNoteTasksStats(a.content || '').completed
|
||||
valB = getNoteTasksStats(b.content || '').completed
|
||||
} else {
|
||||
valA = new Date(a.updatedAt).getTime()
|
||||
valB = new Date(b.updatedAt).getTime()
|
||||
@@ -357,86 +286,6 @@ export function NotesListViews({
|
||||
sortDirection === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />
|
||||
) : null
|
||||
|
||||
if (viewType === 'tasks') {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-foreground/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-[10px] uppercase font-bold tracking-[0.2em] text-muted-foreground">
|
||||
{t('notes.tasksHeader')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono font-bold text-foreground bg-foreground/[0.03] dark:bg-white/5 py-1 px-3 rounded-full">
|
||||
{t('notes.tasksSummary')
|
||||
.replace('{count}', String(extractTasks.length))
|
||||
.replace('{completed}', String(completedTasksCount))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{extractTasks.length > 0 ? (
|
||||
<div className="overflow-hidden border border-border/40 rounded-2xl bg-card/30 shadow-sm">
|
||||
<div className="divide-y divide-foreground/[0.04]">
|
||||
{extractTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-4 flex items-center justify-between gap-4 hover:bg-foreground/[0.01] transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3.5 flex-grow min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleTask(task)}
|
||||
className={cn(
|
||||
'w-5 h-5 rounded-md border flex items-center justify-center transition-all shrink-0',
|
||||
task.completed
|
||||
? 'bg-brand-accent border-brand-accent text-white'
|
||||
: 'border-border hover:border-brand-accent/60 bg-transparent',
|
||||
)}
|
||||
>
|
||||
{task.completed && <span className="text-xs font-bold">✓</span>}
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[13px] font-light leading-relaxed truncate',
|
||||
task.completed && 'line-through text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{task.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[9.5px] uppercase font-mono tracking-wider text-muted-foreground max-w-[140px] truncate">
|
||||
{t('notes.taskFromNote').replace('{title}', task.noteTitle)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const note = allDisplayNotes.find((n) => n.id === task.noteId)
|
||||
if (note) onOpen(note)
|
||||
}}
|
||||
className="p-1.5 rounded-full hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-all"
|
||||
title={t('notes.openSourceNote')}
|
||||
>
|
||||
<Link2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center border border-border/40">
|
||||
<CheckSquare size={18} className="text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="font-memento-serif text-lg italic text-muted-foreground">{t('notes.tasksEmptyTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-sm leading-relaxed">{t('notes.tasksEmptyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (layoutMode === 'grid') {
|
||||
return (
|
||||
<NotesMasonryGrid
|
||||
@@ -484,15 +333,7 @@ export function NotesListViews({
|
||||
{t('notes.tableLabels')}
|
||||
</th>
|
||||
<th
|
||||
className="w-[12%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('tasks')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('notes.tableTasks')} <SortIcon field="tasks" />
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className="w-[13%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
className="w-[18%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('modified')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -506,7 +347,6 @@ export function NotesListViews({
|
||||
const title = getNoteDisplayTitle(note, untitled)
|
||||
const nb = notebooks.find((n) => n.id === note.notebookId)
|
||||
const nbColor = getNotebookColor(note.notebookId, nb?.name)
|
||||
const stats = getNoteTasksStats(note.content || '')
|
||||
return (
|
||||
<tr
|
||||
key={note.id}
|
||||
@@ -538,15 +378,6 @@ export function NotesListViews({
|
||||
<td className="px-4 py-2">
|
||||
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={3} />
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-[10.5px] font-bold text-foreground/80">
|
||||
{stats.total > 0 ? (
|
||||
<span className={stats.completed === stats.total ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}>
|
||||
{stats.completed}/{stats.total} <span className="text-[9px] font-sans">✓</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-[10.5px] font-mono text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}
|
||||
</td>
|
||||
@@ -834,9 +665,9 @@ function GridCard({
|
||||
}: GridCardSharedProps) {
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
const hydrated = useHydrated()
|
||||
const title = getNoteDisplayTitle(note, untitled)
|
||||
const excerpt = getNotePlainExcerpt(note, 110)
|
||||
const stats = getNoteTasksStats(note.content || '')
|
||||
const formattedDate = formatGridCardDate(note.updatedAt, language)
|
||||
|
||||
const handlePinClick = (e: React.MouseEvent) => {
|
||||
@@ -857,9 +688,9 @@ function GridCard({
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={isOverlay ? false : { opacity: 0, y: 15 }}
|
||||
initial={isOverlay || !hydrated ? false : { opacity: 0, y: 15 }}
|
||||
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={isOverlay ? undefined : { delay: 0.04 * index, duration: 0.5 }}
|
||||
transition={isOverlay || !hydrated ? undefined : { delay: 0.04 * index, duration: 0.5 }}
|
||||
onClick={() => onOpen(note)}
|
||||
className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative h-full"
|
||||
>
|
||||
@@ -874,11 +705,6 @@ function GridCard({
|
||||
<Pin size={11} className="fill-amber-500" />
|
||||
</div>
|
||||
)}
|
||||
{stats.total > 0 && (
|
||||
<div className="absolute top-3 end-3 bg-background/90 backdrop-blur-sm py-1 px-2.5 rounded-full shadow-sm border border-border/40 text-[9.5px] font-mono font-bold text-muted-foreground">
|
||||
{stats.completed}/{stats.total} ✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 flex flex-col flex-1 min-h-[11.5rem]">
|
||||
<div className="space-y-2.5 flex-1">
|
||||
|
||||
@@ -25,13 +25,25 @@ import { ChartExtension } from './tiptap-chart-extension'
|
||||
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
||||
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
||||
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
||||
import { DatabaseBlockExtension, insertDatabaseBlockAtSelection } from './tiptap-database-block-extension'
|
||||
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
|
||||
import { ClipArticleExtension } from './tiptap-clip-article-extension'
|
||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
||||
import { EditorBlockDragHandle } from './editor-block-drag-handle'
|
||||
import { BlockActionMenu } from './block-action-menu'
|
||||
import { SmartPasteMenu } from './smart-paste-menu'
|
||||
import { globalDragHandleExtensions } from '@/lib/editor/global-drag-handle-extension'
|
||||
import { resolveBlockAtDragHandle } from '@/lib/editor/block-at-drag-handle'
|
||||
import { parseBlockReferenceFromText, recallLastBlockReference, type ParsedBlockReference } from '@/lib/editor/parse-block-reference'
|
||||
import { getEmptyParagraphAtSelection } from '@/lib/editor/empty-paragraph-at-selection'
|
||||
import { SmartPasteExtension } from '@/lib/editor/smart-paste-extension'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
|
||||
import { applyClipRtlDirection } from '@/lib/editor/apply-clip-rtl-direction'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import { openNotePeek } from '@/lib/note-peek-sync'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
@@ -42,7 +54,7 @@ import {
|
||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3
|
||||
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -64,6 +76,7 @@ interface RichTextEditorProps {
|
||||
placeholder?: string
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
noteId?: string
|
||||
noteTitle?: string
|
||||
/** URL source du clip (BBC Persian, etc.) — pour RTL explicite des listes */
|
||||
sourceUrl?: string | null
|
||||
}
|
||||
@@ -86,7 +99,7 @@ type SlashItem = {
|
||||
|
||||
type SlashCategoryId = 'basic' | 'media' | 'formatting' | 'ai'
|
||||
|
||||
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId }
|
||||
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId; slashKeywords?: string[] }
|
||||
|
||||
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['basic', 'media', 'formatting', 'ai']
|
||||
|
||||
@@ -176,6 +189,10 @@ const slashCommands: SlashItem[] = [
|
||||
window.dispatchEvent(new CustomEvent('memento-open-block-picker'))
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Database', description: 'Inline authors & works database', icon: Database, category: 'Basic blocks', shortcut: '/database',
|
||||
command: (e) => { insertDatabaseBlockAtSelection(e) },
|
||||
},
|
||||
]
|
||||
|
||||
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||
@@ -233,11 +250,28 @@ function useImageInsert() {
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, sourceUrl }, ref) {
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, noteTitle, sourceUrl }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
|
||||
const [blockMenuState, setBlockMenuState] = useState<{
|
||||
anchor: DOMRect
|
||||
pos: number
|
||||
node: PMNode | null
|
||||
} | null>(null)
|
||||
const dragBlockRef = useRef<{ node: PMNode | null; pos: number }>({ node: null, pos: -1 })
|
||||
const smartPastePendingRef = useRef<{
|
||||
reference: ParsedBlockReference
|
||||
blockPos: number
|
||||
blockNode: PMNode
|
||||
blockStatus?: { exists: boolean; content: string; sourceNoteTitle: string }
|
||||
} | null>(null)
|
||||
const [smartPasteMenu, setSmartPasteMenu] = useState<{
|
||||
anchor: { top: number; left: number }
|
||||
reference: ParsedBlockReference
|
||||
sourceNoteTitle?: string
|
||||
} | null>(null)
|
||||
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
|
||||
const [noteLinkQuery, setNoteLinkQuery] = useState('')
|
||||
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
|
||||
@@ -355,16 +389,38 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
Typography,
|
||||
ChartExtension,
|
||||
UniqueIdExtension,
|
||||
...globalDragHandleExtensions,
|
||||
SmartPasteExtension,
|
||||
LiveBlockExtension,
|
||||
DatabaseBlockExtension,
|
||||
ClipArticleExtension,
|
||||
RtlPreserveExtension,
|
||||
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
||||
],
|
||||
content: content || '',
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
attributes: { class: 'notion-editor' },
|
||||
attributes: { class: 'notion-editor tiptap' },
|
||||
handleDOMEvents: {
|
||||
keydown: (view, event) => {
|
||||
if (event.defaultPrevented) return false
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return false
|
||||
const { from, empty } = view.state.selection
|
||||
if (!empty) return false
|
||||
const textBefore = view.state.doc.textBetween(Math.max(0, from - 32), from, '\n')
|
||||
if (!/\/(database|db)$/i.test(textBefore)) return false
|
||||
event.preventDefault()
|
||||
const slashIdx = textBefore.lastIndexOf('/')
|
||||
const deleteFrom = from - (textBefore.length - slashIdx)
|
||||
const ed = editorInstanceRef.current
|
||||
if (!ed) return false
|
||||
ed.chain().focus().deleteRange({ from: deleteFrom, to: from }).run()
|
||||
if (!insertDatabaseBlockAtSelection(ed)) {
|
||||
toast.error(t('databaseBlock.insertFailed'))
|
||||
}
|
||||
return true
|
||||
},
|
||||
click: (_view, event) => {
|
||||
const link = (event.target as HTMLElement).closest('a[href]')
|
||||
if (!link) return false
|
||||
@@ -373,11 +429,12 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
if (noteIdMatch) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
window.open(
|
||||
`/home?openNote=${decodeURIComponent(noteIdMatch[1])}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
const targetId = decodeURIComponent(noteIdMatch[1])
|
||||
const blockMatch = href.match(/#block-([^&#]+)/)
|
||||
openNotePeek({
|
||||
noteId: targetId,
|
||||
blockId: blockMatch ? decodeURIComponent(blockMatch[1]) : undefined,
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||
@@ -405,11 +462,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
try {
|
||||
toast.info(t('notes.uploading'))
|
||||
const url = await onImageUpload(file)
|
||||
const editorInstance = editorInstanceRef.current
|
||||
if (!editorInstance) continue
|
||||
const inserted = editorInstance.chain().focus().setImage({ src: url }).run()
|
||||
const ed = editorInstanceRef.current
|
||||
if (!ed) continue
|
||||
const inserted = ed.chain().focus().setImage({ src: url }).run()
|
||||
if (inserted) {
|
||||
emitContentChange(editorInstance.getHTML())
|
||||
emitContentChange(ed.getHTML())
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed'))
|
||||
@@ -447,6 +504,80 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
editorInstanceRef.current = editor ?? null
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
editor.storage.liveBlock.hostNoteId = noteId ?? null
|
||||
}, [editor, noteId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
editor.storage.smartPaste.onPaste = (view, event) => {
|
||||
const clipboardText = event.clipboardData?.getData('text/plain') ?? ''
|
||||
let blockRef = parseBlockReferenceFromText(clipboardText)
|
||||
if (!blockRef) {
|
||||
blockRef = recallLastBlockReference()
|
||||
}
|
||||
if (!blockRef) return false
|
||||
|
||||
const emptyParagraph = getEmptyParagraphAtSelection(view.state)
|
||||
if (!emptyParagraph) return false
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const coords = view.coordsAtPos(view.state.selection.from)
|
||||
smartPastePendingRef.current = {
|
||||
reference: blockRef,
|
||||
blockPos: emptyParagraph.pos,
|
||||
blockNode: emptyParagraph.node,
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
setSmartPasteMenu({
|
||||
anchor: { top: coords.bottom, left: coords.left },
|
||||
reference: blockRef,
|
||||
})
|
||||
})
|
||||
|
||||
void fetch(
|
||||
`/api/blocks/${encodeURIComponent(blockRef.blockId)}/status?sourceNoteId=${encodeURIComponent(blockRef.sourceNoteId)}`,
|
||||
)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: { content?: string; sourceNoteTitle?: string; exists?: boolean } | null) => {
|
||||
if (smartPastePendingRef.current?.reference.raw !== blockRef.raw) return
|
||||
const recalled = recallLastBlockReference()
|
||||
const sessionFallback =
|
||||
recalled?.raw === blockRef.raw
|
||||
? {
|
||||
content: recalled.blockContent?.trim() ?? '',
|
||||
sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '',
|
||||
}
|
||||
: { content: '', sourceNoteTitle: '' }
|
||||
const exists = Boolean(data?.exists) || sessionFallback.content.length > 0
|
||||
const content = data?.exists ? (data.content ?? '') : (sessionFallback.content || data?.content || '')
|
||||
const sourceNoteTitle = data?.sourceNoteTitle || sessionFallback.sourceNoteTitle || ''
|
||||
smartPastePendingRef.current!.blockStatus = {
|
||||
exists,
|
||||
content,
|
||||
sourceNoteTitle,
|
||||
}
|
||||
setSmartPasteMenu((prev) =>
|
||||
prev?.reference.raw === blockRef.raw
|
||||
? { ...prev, sourceNoteTitle: sourceNoteTitle || prev.sourceNoteTitle }
|
||||
: prev,
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return () => {
|
||||
editor.storage.smartPaste.onPaste = null
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Chart suggestions dialog state
|
||||
const [chartSuggestionsOpen, setChartSuggestionsOpen] = useState(false)
|
||||
const [currentNoteContent, setCurrentNoteContent] = useState(content || '')
|
||||
@@ -620,6 +751,159 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
|
||||
handleSelectBlockRef.current = handleSelectBlock
|
||||
|
||||
const openBlockActionMenu = useCallback((anchorRect: DOMRect) => {
|
||||
if (!editor) return
|
||||
const block = resolveBlockAtDragHandle(editor)
|
||||
if (!block) return
|
||||
dragBlockRef.current = block
|
||||
setBlockMenuState({ anchor: anchorRect, pos: block.pos, node: block.node })
|
||||
}, [editor])
|
||||
|
||||
const closeBlockActionMenu = useCallback(() => {
|
||||
setBlockMenuState(null)
|
||||
}, [])
|
||||
|
||||
const closeSmartPasteMenu = useCallback(() => {
|
||||
smartPastePendingRef.current = null
|
||||
setSmartPasteMenu(null)
|
||||
}, [])
|
||||
|
||||
const handleBlockReferenceCopied = useCallback((html: string) => {
|
||||
emitContentChange(html)
|
||||
if (noteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId, reason: 'block-reference-copy' },
|
||||
}))
|
||||
}
|
||||
}, [emitContentChange, noteId])
|
||||
|
||||
const fetchBlockStatus = useCallback(async (reference: ParsedBlockReference) => {
|
||||
const cached = smartPastePendingRef.current?.blockStatus
|
||||
if (cached && smartPastePendingRef.current?.reference.raw === reference.raw) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const recalled = recallLastBlockReference()
|
||||
const sessionFallback =
|
||||
recalled?.raw === reference.raw
|
||||
? {
|
||||
content: recalled.blockContent?.trim() ?? '',
|
||||
sourceNoteTitle: recalled.sourceNoteTitle?.trim() ?? '',
|
||||
}
|
||||
: { content: '', sourceNoteTitle: '' }
|
||||
|
||||
const res = await fetch(
|
||||
`/api/blocks/${encodeURIComponent(reference.blockId)}/status?sourceNoteId=${encodeURIComponent(reference.sourceNoteId)}`,
|
||||
)
|
||||
if (!res.ok) {
|
||||
return {
|
||||
exists: sessionFallback.content.length > 0,
|
||||
content: sessionFallback.content,
|
||||
sourceNoteTitle: sessionFallback.sourceNoteTitle,
|
||||
}
|
||||
}
|
||||
const data = await res.json()
|
||||
if (data.exists) {
|
||||
return {
|
||||
exists: true,
|
||||
content: data.content ?? '',
|
||||
sourceNoteTitle: data.sourceNoteTitle ?? '',
|
||||
}
|
||||
}
|
||||
return {
|
||||
exists: sessionFallback.content.length > 0,
|
||||
content: sessionFallback.content || data.content || '',
|
||||
sourceNoteTitle: sessionFallback.sourceNoteTitle || data.sourceNoteTitle || '',
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSmartPasteLive = useCallback(async () => {
|
||||
const pending = smartPastePendingRef.current
|
||||
const ed = editorInstanceRef.current
|
||||
if (!pending || !ed) {
|
||||
closeSmartPasteMenu()
|
||||
return
|
||||
}
|
||||
|
||||
const status = await fetchBlockStatus(pending.reference)
|
||||
|
||||
if (noteId) {
|
||||
try {
|
||||
await fetch('/api/blocks/embed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceNoteId: pending.reference.sourceNoteId,
|
||||
blockId: pending.reference.blockId,
|
||||
targetNoteId: noteId,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
const liveBlockType = ed.schema.nodes.liveBlock
|
||||
if (liveBlockType) {
|
||||
ed.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
const node = liveBlockType.create({
|
||||
sourceNoteId: pending.reference.sourceNoteId,
|
||||
blockId: pending.reference.blockId,
|
||||
snapshotContent: status.content,
|
||||
sourceNoteTitle: status.sourceNoteTitle,
|
||||
})
|
||||
tr.replaceWith(pending.blockPos, pending.blockPos + pending.blockNode.nodeSize, node)
|
||||
if (dispatch) dispatch(tr)
|
||||
return true
|
||||
})
|
||||
.run()
|
||||
emitContentChange(ed.getHTML())
|
||||
if (noteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId, reason: 'smart-paste-live-block' },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
closeSmartPasteMenu()
|
||||
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus, noteId])
|
||||
|
||||
const handleSmartPastePlain = useCallback(async () => {
|
||||
const pending = smartPastePendingRef.current
|
||||
const ed = editorInstanceRef.current
|
||||
if (!pending || !ed) {
|
||||
closeSmartPasteMenu()
|
||||
return
|
||||
}
|
||||
|
||||
const status = await fetchBlockStatus(pending.reference)
|
||||
const linkHref = pending.reference.raw.match(/^https?:\/\//)
|
||||
? pending.reference.raw
|
||||
: `${window.location.origin}/home?openNote=${encodeURIComponent(pending.reference.sourceNoteId)}#block-${pending.reference.blockId}`
|
||||
const linkText = status.sourceNoteTitle?.trim() || pending.reference.raw
|
||||
|
||||
ed.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: linkText,
|
||||
marks: [{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: linkHref,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
}],
|
||||
})
|
||||
.run()
|
||||
|
||||
emitContentChange(ed.getHTML())
|
||||
closeSmartPasteMenu()
|
||||
}, [closeSmartPasteMenu, emitContentChange, fetchBlockStatus])
|
||||
|
||||
return (
|
||||
<div className={cn('notion-editor-wrapper', className)}>
|
||||
{editor && (
|
||||
@@ -645,8 +929,34 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
|
||||
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} onSuggestCharts={handleOpenChartSuggestions} />}
|
||||
|
||||
<EditorBlockDragHandle editor={editor} onOpenMenu={openBlockActionMenu} />
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
{editor && blockMenuState && (
|
||||
<BlockActionMenu
|
||||
editor={editor}
|
||||
anchorRect={blockMenuState.anchor}
|
||||
blockPos={blockMenuState.pos}
|
||||
blockNode={blockMenuState.node}
|
||||
noteId={noteId}
|
||||
sourceNoteTitle={noteTitle}
|
||||
onBlockReferenceCopied={handleBlockReferenceCopied}
|
||||
onClose={closeBlockActionMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{smartPasteMenu && (
|
||||
<SmartPasteMenu
|
||||
anchor={smartPasteMenu.anchor}
|
||||
reference={smartPasteMenu.reference}
|
||||
sourceNoteTitle={smartPasteMenu.sourceNoteTitle}
|
||||
onLive={() => { void handleSmartPasteLive() }}
|
||||
onPlain={() => { void handleSmartPastePlain() }}
|
||||
onClose={closeSmartPasteMenu}
|
||||
/>
|
||||
)}
|
||||
|
||||
{imageInsert.open && (
|
||||
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
|
||||
)}
|
||||
@@ -973,6 +1283,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[28], title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', categoryId: 'ai' },
|
||||
{ ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'basic' },
|
||||
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'basic', slashKeywords: ['database', 'db', 'base', 'données', 'donnees'] },
|
||||
{
|
||||
title: t('richTextEditor.slashNoteLink'),
|
||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||
@@ -1021,6 +1332,11 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
finally { setAiLoading(false) }
|
||||
} else if (item.title === 'Suggest Charts') {
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else if (item.title === t('richTextEditor.slashDatabase')) {
|
||||
deleteSlashText(); closeMenu()
|
||||
if (!insertDatabaseBlockAtSelection(editor)) {
|
||||
toast.error(t('databaseBlock.insertFailed'))
|
||||
}
|
||||
} else {
|
||||
deleteSlashText(); item.command(editor); closeMenu()
|
||||
}
|
||||
@@ -1029,7 +1345,13 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
const presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
|
||||
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
|
||||
|
||||
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
|
||||
const q = query.toLowerCase()
|
||||
const textFiltered = localCommands.filter(c =>
|
||||
c.title.toLowerCase().includes(q)
|
||||
|| c.description.toLowerCase().includes(q)
|
||||
|| (c.shortcut?.toLowerCase().includes(q) ?? false)
|
||||
|| (c.slashKeywords?.some((kw) => kw.includes(q) || q.includes(kw)) ?? false)
|
||||
)
|
||||
const filtered = activeCategory ? textFiltered.filter(c => c.categoryId === activeCategory) : textFiltered
|
||||
|
||||
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
|
||||
@@ -1070,7 +1392,15 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = filtered[selectedIndex]
|
||||
if (item) handleSelect(item)
|
||||
if (item) {
|
||||
handleSelect(item)
|
||||
} else if (/^(database|db)$/i.test(query)) {
|
||||
deleteSlashText()
|
||||
closeMenu()
|
||||
if (!insertDatabaseBlockAtSelection(editor)) {
|
||||
toast.error(t('databaseBlock.insertFailed'))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return }
|
||||
|
||||
@@ -40,7 +40,7 @@ import { useSearchModal } from '@/context/search-modal-context'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { applyDocumentTheme } from '@/lib/apply-document-theme'
|
||||
import { getAllNotes, getTrashCount } from '@/app/actions/notes'
|
||||
import { getAllNotes, getTrashCount, getNotesWithReminders } from '@/app/actions/notes'
|
||||
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { Notebook, Note } from '@/lib/types'
|
||||
@@ -60,7 +60,7 @@ import { performSignOut } from '@/lib/auth-client'
|
||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||
import { UsageMeter } from './usage-meter'
|
||||
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision'
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
|
||||
function NoteLink({
|
||||
@@ -175,6 +175,96 @@ function SidebarBrainstorms() {
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarReminders({ onOpenNote }: { onOpenNote: (noteId: string, notebookId: string | null) => void }) {
|
||||
const { t } = useLanguage()
|
||||
const [reminders, setReminders] = useState<
|
||||
{ id: string; title: string | null; reminder: Date | string | null; isReminderDone: boolean; notebookId: string | null }[]
|
||||
>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
getNotesWithReminders()
|
||||
.then((rows) => {
|
||||
if (!cancelled) setReminders(rows as typeof reminders)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-4 space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-10 rounded-xl bg-paper/50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const active = reminders.filter((r) => !r.isReminderDone && r.reminder)
|
||||
const overdue = active.filter((r) => new Date(r.reminder!) < now)
|
||||
const upcoming = active.filter((r) => new Date(r.reminder!) >= now)
|
||||
|
||||
if (active.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30 mx-4">
|
||||
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
|
||||
<p className="text-[11px] text-concrete italic">{t('reminders.emptyDescription')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderItem = (note: (typeof active)[0], overdueItem?: boolean) => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onOpenNote(note.id, note.notebookId)}
|
||||
className="w-full text-start px-4 py-2.5 rounded-xl hover:bg-brand-accent/5 transition-colors group"
|
||||
>
|
||||
<p className="text-[12px] font-medium truncate group-hover:text-brand-accent transition-colors">
|
||||
{note.title || t('notes.untitled')}
|
||||
</p>
|
||||
<p className={cn('text-[10px] mt-0.5', overdueItem ? 'text-red-500' : 'text-muted-foreground')}>
|
||||
{note.reminder &&
|
||||
new Date(note.reminder).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-2">
|
||||
{overdue.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-red-500 px-4 mb-1">
|
||||
{t('reminders.overdue')}
|
||||
</p>
|
||||
<div className="space-y-0.5">{overdue.map((n) => renderItem(n, true))}</div>
|
||||
</div>
|
||||
)}
|
||||
{upcoming.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground px-4 mb-1">
|
||||
{t('reminders.upcoming')}
|
||||
</p>
|
||||
<div className="space-y-0.5">{upcoming.map((n) => renderItem(n))}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarCarnetItem({
|
||||
carnet,
|
||||
isActive,
|
||||
@@ -536,8 +626,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith('/brainstorm')) setActiveView('brainstorms')
|
||||
else if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) setActiveView('agents')
|
||||
else setActiveView('notebooks')
|
||||
}, [pathname])
|
||||
else if (pathname === '/insights') setActiveView('insights')
|
||||
else if (pathname.startsWith('/revision')) setActiveView('revision')
|
||||
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
|
||||
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
|
||||
}, [pathname, searchParams])
|
||||
|
||||
const isRemindersRoute = pathname === '/home' && searchParams.get('reminders') === '1'
|
||||
const isSharedRoute = pathname === '/home' && searchParams.get('shared') === '1'
|
||||
const isNotebooksRoute =
|
||||
(pathname === '/home' || pathname.startsWith('/notes')) &&
|
||||
!pathname.startsWith('/settings') &&
|
||||
!isRemindersRoute &&
|
||||
!isSharedRoute
|
||||
|
||||
const displayName = user?.name || user?.email || ''
|
||||
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
|
||||
@@ -645,6 +746,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleRemindersClick = () => {
|
||||
setActiveView('reminders')
|
||||
router.push('/home?reminders=1&forceList=1')
|
||||
}
|
||||
|
||||
const handleReminderNoteClick = (noteId: string, notebookId: string | null) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('reminders', '1')
|
||||
if (notebookId) params.set('notebook', notebookId)
|
||||
params.set('openNote', noteId)
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleInboxClick = () => {
|
||||
router.push('/home?forceList=1')
|
||||
}
|
||||
@@ -972,14 +1086,59 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{/* Boutons de navigation principaux */}
|
||||
<div className="flex flex-col gap-1.5 w-full px-1.5">
|
||||
{([
|
||||
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
|
||||
{ id: 'insights', icon: Sparkles, label: t('nav.insights'), onClick: () => router.push('/insights'), isActive: pathname === '/insights' },
|
||||
{ id: 'revision', icon: GraduationCap, label: t('nav.revision'), onClick: () => router.push('/revision'), isActive: pathname === '/revision' },
|
||||
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
|
||||
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
|
||||
{
|
||||
id: 'notebooks',
|
||||
icon: BookOpen,
|
||||
label: t('nav.notebooks'),
|
||||
onClick: () => {
|
||||
setActiveView('notebooks')
|
||||
router.push('/home?forceList=1')
|
||||
},
|
||||
isActive: isNotebooksRoute,
|
||||
},
|
||||
{
|
||||
id: 'insights',
|
||||
icon: Sparkles,
|
||||
label: t('nav.insights'),
|
||||
onClick: () => {
|
||||
setActiveView('insights')
|
||||
router.push('/insights')
|
||||
},
|
||||
isActive: pathname === '/insights',
|
||||
},
|
||||
{
|
||||
id: 'revision',
|
||||
icon: GraduationCap,
|
||||
label: t('nav.revision'),
|
||||
onClick: () => {
|
||||
setActiveView('revision')
|
||||
router.push('/revision')
|
||||
},
|
||||
isActive: pathname.startsWith('/revision'),
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
icon: Bot,
|
||||
label: t('agents.intelligenceOS') || 'Intelligence IA',
|
||||
onClick: () => {
|
||||
setActiveView('agents')
|
||||
router.push('/agents')
|
||||
},
|
||||
isActive: pathname.startsWith('/agents') || pathname.startsWith('/lab'),
|
||||
},
|
||||
{
|
||||
id: 'reminders',
|
||||
icon: Bell,
|
||||
label: t('sidebar.reminders'),
|
||||
onClick: handleRemindersClick,
|
||||
isActive: isRemindersRoute,
|
||||
},
|
||||
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-label={item.label}
|
||||
aria-current={item.isActive ? 'page' : undefined}
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
||||
@@ -992,7 +1151,10 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-brand-accent rounded-r-full" />
|
||||
)}
|
||||
<item.icon size={16} />
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
@@ -1232,6 +1394,60 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : activeView === 'insights' ? (
|
||||
<motion.div
|
||||
key="insights"
|
||||
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="px-4 pt-4 space-y-4"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles size={14} className="text-brand-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
||||
{t('nav.insights')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
||||
{t('sidebar.insightsPanelBody')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/home')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
||||
{t('sidebar.backToNotebooks')}
|
||||
</button>
|
||||
</motion.div>
|
||||
) : activeView === 'revision' ? (
|
||||
<motion.div
|
||||
key="revision"
|
||||
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="px-4 pt-4 space-y-4"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<GraduationCap size={14} className="text-brand-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
||||
{t('nav.revision')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-muted-foreground">
|
||||
{t('sidebar.revisionPanelBody')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/home')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
||||
{t('sidebar.backToNotebooks')}
|
||||
</button>
|
||||
</motion.div>
|
||||
) : activeView === 'reminders' ? (
|
||||
<motion.div
|
||||
key="reminders"
|
||||
@@ -1239,13 +1455,16 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex flex-col min-h-0"
|
||||
>
|
||||
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase mb-4 px-4">
|
||||
<div className="flex items-center gap-1.5 px-4 pt-4 mb-3 shrink-0">
|
||||
<Bell size={14} className="text-brand-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
||||
{t('sidebar.reminders')}
|
||||
</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">{t('sidebar.noReminders')}</p>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar min-h-0">
|
||||
<SidebarReminders onOpenNote={handleReminderNoteClick} />
|
||||
</div>
|
||||
</motion.div>
|
||||
) : activeView === 'agents' ? (
|
||||
|
||||
80
memento-note/components/smart-paste-menu.tsx
Normal file
80
memento-note/components/smart-paste-menu.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Link2, Blocks } from 'lucide-react'
|
||||
import type { ParsedBlockReference } from '@/lib/editor/parse-block-reference'
|
||||
|
||||
export type SmartPasteMenuProps = {
|
||||
anchor: { top: number; left: number }
|
||||
reference: ParsedBlockReference
|
||||
sourceNoteTitle?: string
|
||||
onLive: () => void
|
||||
onPlain: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SmartPasteMenu({
|
||||
anchor,
|
||||
reference,
|
||||
sourceNoteTitle,
|
||||
onLive,
|
||||
onPlain,
|
||||
onClose,
|
||||
}: SmartPasteMenuProps) {
|
||||
const { t } = useLanguage()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const menuStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: anchor.left,
|
||||
top: anchor.top + 8,
|
||||
zIndex: 9999,
|
||||
maxWidth: 320,
|
||||
}
|
||||
|
||||
if (Number(menuStyle.left) + 320 > window.innerWidth) {
|
||||
menuStyle.left = Math.max(8, window.innerWidth - 328)
|
||||
}
|
||||
if (Number(menuStyle.top) + 140 > window.innerHeight) {
|
||||
menuStyle.top = anchor.top - 132
|
||||
}
|
||||
|
||||
const previewTitle = sourceNoteTitle?.trim() || t('smartPaste.unknownNote')
|
||||
|
||||
return createPortal(
|
||||
<div ref={menuRef} style={menuStyle} className="block-action-menu smart-paste-menu">
|
||||
<p className="smart-paste-menu__hint">{t('smartPaste.prompt')}</p>
|
||||
<p className="smart-paste-menu__source" title={reference.raw}>
|
||||
{previewTitle}
|
||||
</p>
|
||||
<button type="button" className="block-action-item" onClick={onLive}>
|
||||
<Blocks size={16} />
|
||||
<span>{t('smartPaste.liveBlock')}</span>
|
||||
</button>
|
||||
<button type="button" className="block-action-item" onClick={onPlain}>
|
||||
<Link2 size={16} />
|
||||
<span>{t('smartPaste.plainLink')}</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
117
memento-note/components/tiptap-database-block-extension.tsx
Normal file
117
memento-note/components/tiptap-database-block-extension.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
|
||||
import { useCallback } from 'react'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { DatabaseBlockEditor } from '@/components/database-block-editor'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import {
|
||||
createDefaultDatabaseBlockData,
|
||||
parseDatabaseBlockAttrs,
|
||||
serializeDatabaseBlockData,
|
||||
type DatabaseBlockData,
|
||||
} from '@/lib/editor/database-block-types'
|
||||
|
||||
function DatabaseBlockView({ node, updateAttributes, editor }: NodeViewProps) {
|
||||
const data = parseDatabaseBlockAttrs(node.attrs)
|
||||
|
||||
const requestSave = useCallback(() => {
|
||||
const hostNoteId = editor.storage.liveBlock?.hostNoteId
|
||||
if (hostNoteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId: hostNoteId, reason: 'database-block-mutation' },
|
||||
}))
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const handleChange = useCallback((next: DatabaseBlockData) => {
|
||||
updateAttributes(serializeDatabaseBlockData(next))
|
||||
requestSave()
|
||||
}, [requestSave, updateAttributes])
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="database-block-wrapper" data-drag-handle contentEditable={false}>
|
||||
<DatabaseBlockEditor data={data} onChange={handleChange} />
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const DatabaseBlockExtension = Node.create({
|
||||
name: 'databaseBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
dbId: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-db-id') || '',
|
||||
renderHTML: (attributes) => (attributes.dbId ? { 'data-db-id': attributes.dbId } : {}),
|
||||
},
|
||||
dbView: {
|
||||
default: 'table',
|
||||
parseHTML: (element) => element.getAttribute('data-db-view') || 'table',
|
||||
renderHTML: (attributes) => ({ 'data-db-view': attributes.dbView || 'table' }),
|
||||
},
|
||||
dbAuthorsJson: {
|
||||
default: '[]',
|
||||
parseHTML: (element) => element.getAttribute('data-db-authors') || '[]',
|
||||
renderHTML: (attributes) => ({ 'data-db-authors': attributes.dbAuthorsJson || '[]' }),
|
||||
},
|
||||
dbBooksJson: {
|
||||
default: '[]',
|
||||
parseHTML: (element) => element.getAttribute('data-db-books') || '[]',
|
||||
renderHTML: (attributes) => ({ 'data-db-books': attributes.dbBooksJson || '[]' }),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-database-block]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-database-block': 'true' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(DatabaseBlockView)
|
||||
},
|
||||
})
|
||||
|
||||
export function insertDatabaseBlockAtSelection(editor: Editor): boolean {
|
||||
const type = editor.schema.nodes.databaseBlock
|
||||
if (!type) return false
|
||||
|
||||
const attrs = serializeDatabaseBlockData(createDefaultDatabaseBlockData())
|
||||
const { empty, $from } = editor.state.selection
|
||||
const parent = $from.parent
|
||||
|
||||
if (empty && parent.type.name === 'paragraph' && parent.content.size === 0) {
|
||||
const pos = $from.before()
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
tr.replaceWith(pos, pos + parent.nodeSize, type.create(attrs))
|
||||
if (dispatch) dispatch(tr)
|
||||
return true
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
return editor.chain().focus().insertContent({ type: 'databaseBlock', attrs }).run()
|
||||
}
|
||||
|
||||
export function replaceBlockWithDatabase(editor: Editor, blockPos: number, blockNode: PMNode): void {
|
||||
const type = editor.schema.nodes.databaseBlock
|
||||
if (!type || blockPos < 0 || !blockNode) return
|
||||
const attrs = serializeDatabaseBlockData(createDefaultDatabaseBlockData())
|
||||
const dbNode = type.create(attrs)
|
||||
editor.view.dispatch(editor.state.tr.replaceWith(blockPos, blockPos + blockNode.nodeSize, dbNode))
|
||||
editor.commands.focus()
|
||||
}
|
||||
@@ -1,28 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from '@tiptap/react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { Zap, AlertCircle, Unlink, ArrowRight } from 'lucide-react'
|
||||
import { Zap, AlertCircle, Unlink, ArrowRight, Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import { openNotePeek } from '@/lib/note-peek-sync'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Storage {
|
||||
liveBlock: {
|
||||
hostNoteId: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LiveBlock Node View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LiveBlockViewProps {
|
||||
node: {
|
||||
attrs: {
|
||||
sourceNoteId: string
|
||||
blockId: string
|
||||
snapshotContent: string
|
||||
sourceNoteTitle: string
|
||||
}
|
||||
}
|
||||
updateAttributes: (attrs: Record<string, string>) => void
|
||||
deleteNode: () => void
|
||||
}
|
||||
|
||||
function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProps) {
|
||||
function LiveBlockView({ node, updateAttributes, deleteNode, editor, getPos }: NodeViewProps) {
|
||||
const { t } = useLanguage()
|
||||
const { sourceNoteId, blockId, snapshotContent, sourceNoteTitle } = node.attrs
|
||||
const [localContent, setLocalContent] = useState(snapshotContent || '')
|
||||
const [isDeleted, setIsDeleted] = useState(false)
|
||||
@@ -30,6 +29,15 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
const [pulse, setPulse] = useState(false)
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const requestSave = useCallback(() => {
|
||||
const hostNoteId = editor.storage.liveBlock?.hostNoteId
|
||||
if (hostNoteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId: hostNoteId, reason: 'live-block-mutation' },
|
||||
}))
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Fetch current block status on mount
|
||||
useEffect(() => {
|
||||
if (!sourceNoteId || !blockId) return
|
||||
@@ -37,6 +45,10 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
.then(r => r.json())
|
||||
.then((data: { exists: boolean; content: string; sourceNoteTitle: string }) => {
|
||||
if (!data.exists) {
|
||||
if (snapshotContent?.trim()) {
|
||||
setLocalContent(snapshotContent)
|
||||
return
|
||||
}
|
||||
setIsDeleted(true)
|
||||
} else {
|
||||
setLocalContent(data.content)
|
||||
@@ -44,7 +56,7 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
}
|
||||
})
|
||||
.catch(() => setIsOffline(true))
|
||||
}, [sourceNoteId, blockId])
|
||||
}, [sourceNoteId, blockId, snapshotContent, updateAttributes])
|
||||
|
||||
// Listen for real-time block update events
|
||||
useEffect(() => {
|
||||
@@ -70,14 +82,33 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
}
|
||||
}, [blockId, updateAttributes])
|
||||
|
||||
const handleDetach = useCallback(async () => {
|
||||
// Convert this node to a plain paragraph with snapshot text
|
||||
deleteNode()
|
||||
}, [deleteNode])
|
||||
/** Convertit le bloc live en paragraphe local (conserve le texte affiché). */
|
||||
const handleDetach = useCallback(() => {
|
||||
const pos = getPos()
|
||||
if (typeof pos !== 'number') return
|
||||
const currentNode = editor.state.doc.nodeAt(pos)
|
||||
if (!currentNode) return
|
||||
|
||||
const handleOpenSource = useCallback(() => {
|
||||
window.open(`/home?openNote=${encodeURIComponent(sourceNoteId)}`, '_blank', 'noopener,noreferrer')
|
||||
}, [sourceNoteId])
|
||||
const text = localContent.trim()
|
||||
const paragraph = editor.state.schema.nodes.paragraph.create(
|
||||
null,
|
||||
text ? editor.state.schema.text(text) : undefined,
|
||||
)
|
||||
editor.view.dispatch(editor.state.tr.replaceWith(pos, pos + currentNode.nodeSize, paragraph))
|
||||
editor.commands.focus()
|
||||
requestSave()
|
||||
}, [editor, getPos, localContent, requestSave])
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
deleteNode()
|
||||
requestSave()
|
||||
}, [deleteNode, requestSave])
|
||||
|
||||
const handleOpenSource = useCallback((event: React.MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openNotePeek({ noteId: sourceNoteId, blockId: blockId || undefined })
|
||||
}, [sourceNoteId, blockId])
|
||||
|
||||
const borderClass = isDeleted
|
||||
? 'border-l-rose-500 border-y-rose-200 border-r-rose-200 bg-rose-50/20 dark:border-l-red-700 dark:border-y-red-900/40 dark:border-r-red-900/40 dark:bg-red-950/5'
|
||||
@@ -87,54 +118,78 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
? 'border-l-blue-500 border-y-blue-300 border-r-blue-300 bg-blue-50/20 shadow-md shadow-blue-500/15 dark:bg-blue-950/10'
|
||||
: 'border-l-blue-500 border-y-[#E8E6E3] border-r-[#E8E6E3] bg-blue-50/5 dark:border-y-zinc-800 dark:border-r-zinc-800 dark:bg-blue-950/5'
|
||||
|
||||
const headerTitle = isDeleted
|
||||
? t('liveBlock.sourceDisconnected')
|
||||
: (sourceNoteTitle || t('liveBlock.connectedNote'))
|
||||
|
||||
const actionButtonClass =
|
||||
'text-[9.5px] font-bold flex items-center gap-1 transition-all shrink-0'
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className="group/liveblock my-4 not-prose">
|
||||
<div className={`w-full rounded-xl border-l-[3px] border-y border-r transition-all duration-300 overflow-hidden ${borderClass}`}>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-1.5 flex items-center justify-between bg-black/[0.015] dark:bg-white/[0.01] border-b border-black/[0.03] dark:border-white/[0.02]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{isDeleted ? (
|
||||
<AlertCircle size={10} className="text-rose-500 shrink-0" />
|
||||
) : (
|
||||
<Zap size={10} className={`shrink-0 ${isOffline ? 'text-amber-500' : 'text-blue-500 fill-blue-500/20'}`} />
|
||||
)}
|
||||
<span className="text-[10px] font-sans font-medium text-[var(--color-concrete)] hover:text-[var(--color-ink)] transition-colors cursor-default max-w-[200px] truncate">
|
||||
{isDeleted ? 'Source déconnectée' : (sourceNoteTitle || 'Note connectée')}
|
||||
{headerTitle}
|
||||
</span>
|
||||
{isDeleted ? (
|
||||
<span className="bg-rose-500/10 text-rose-600 dark:text-rose-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
|
||||
DÉCONNECTÉ
|
||||
{t('liveBlock.statusDisconnected')}
|
||||
</span>
|
||||
) : isOffline ? (
|
||||
<span className="bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
|
||||
HORS-LIGNE
|
||||
{t('liveBlock.statusOffline')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider animate-pulse">
|
||||
LIVE
|
||||
{t('liveBlock.statusLive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isDeleted ? (
|
||||
<div
|
||||
className={`flex items-center gap-2 shrink-0 ${isDeleted ? '' : 'opacity-0 group-hover/liveblock:opacity-100'} transition-opacity`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDetach}
|
||||
className="text-[9.5px] font-bold text-rose-600 hover:text-rose-500 dark:text-rose-400 flex items-center gap-1 hover:underline transition-all"
|
||||
title={t('liveBlock.detachHelp')}
|
||||
className={`${actionButtonClass} ${
|
||||
isDeleted
|
||||
? 'text-rose-600 hover:text-rose-500 dark:text-rose-400 hover:underline'
|
||||
: 'text-[var(--color-concrete)] hover:text-[var(--color-ink)] dark:hover:text-[var(--color-dark-ink)]'
|
||||
}`}
|
||||
contentEditable={false}
|
||||
>
|
||||
<Unlink size={10} />
|
||||
Décharger le lien
|
||||
{t('liveBlock.detachLink')}
|
||||
</button>
|
||||
) : (
|
||||
{!isDeleted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenSource}
|
||||
className="opacity-0 group-hover/liveblock:opacity-100 flex items-center gap-1 text-[9.5px] font-extrabold text-blue-600 dark:text-blue-400 hover:underline transition-all"
|
||||
className={`${actionButtonClass} text-blue-600 dark:text-blue-400 hover:underline`}
|
||||
contentEditable={false}
|
||||
>
|
||||
Ouvrir <ArrowRight size={10} />
|
||||
{t('liveBlock.openSource')} <ArrowRight size={10} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
title={t('liveBlock.removeBlock')}
|
||||
className={`${actionButtonClass} text-rose-600/80 hover:text-rose-600 dark:text-rose-400/80 dark:hover:text-rose-400`}
|
||||
contentEditable={false}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
@@ -143,7 +198,7 @@ function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProp
|
||||
className="text-sm leading-relaxed text-[var(--color-ink)] opacity-80 dark:text-[var(--color-dark-ink)] font-sans whitespace-pre-wrap"
|
||||
contentEditable={false}
|
||||
>
|
||||
{localContent || '(bloc vide)'}
|
||||
{localContent || t('liveBlock.emptyContent')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,6 +218,12 @@ export const LiveBlockExtension = Node.create({
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
hostNoteId: null as string | null,
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
sourceNoteId: { default: '' },
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import type { Transaction } from '@tiptap/pm/state'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
const BLOCK_TYPES = ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'codeBlock']
|
||||
|
||||
function assignMissingBlockIds(tr: Transaction, doc: PMNode) {
|
||||
let modified = false
|
||||
doc.descendants((node, pos) => {
|
||||
if (!BLOCK_TYPES.includes(node.type.name)) return
|
||||
if (node.attrs['data-id']) return
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
'data-id': generateBlockId(),
|
||||
})
|
||||
modified = true
|
||||
})
|
||||
return modified
|
||||
}
|
||||
|
||||
function generateBlockId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
@@ -17,6 +34,18 @@ function generateBlockId(): string {
|
||||
export const UniqueIdExtension = Extension.create({
|
||||
name: 'uniqueId',
|
||||
|
||||
onCreate({ editor }) {
|
||||
const assign = () => {
|
||||
if (editor.isDestroyed) return
|
||||
const { tr, doc } = editor.state
|
||||
if (assignMissingBlockIds(tr, doc)) {
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
}
|
||||
assign()
|
||||
requestAnimationFrame(assign)
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
@@ -26,20 +55,7 @@ export const UniqueIdExtension = Extension.create({
|
||||
if (!hasDocChanged) return null
|
||||
|
||||
const { tr, doc } = newState
|
||||
let modified = false
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!BLOCK_TYPES.includes(node.type.name)) return
|
||||
if (node.attrs['data-id']) return
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
'data-id': generateBlockId(),
|
||||
})
|
||||
modified = true
|
||||
})
|
||||
|
||||
return modified ? tr : null
|
||||
return assignMissingBlockIds(tr, doc) ? tr : null
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.1 MiB |
@@ -5,6 +5,23 @@ export interface ExtractedBlock {
|
||||
content: string
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/** Contenu texte d'un bloc identifié par data-id (sans filtre longueur min). */
|
||||
export function extractBlockContentById(html: string, blockId: string): string | null {
|
||||
if (!html || !blockId) return null
|
||||
const regex = new RegExp(
|
||||
`<(?:p|h[1-6]|blockquote|li)[^>]*data-id="${escapeRegExp(blockId)}"[^>]*>([\\s\\S]*?)<\\/(?:p|h[1-6]|blockquote|li)>`,
|
||||
'i',
|
||||
)
|
||||
const match = regex.exec(html)
|
||||
if (!match) return null
|
||||
const content = stripHtmlToPlainText(match[1])
|
||||
return content.length > 0 ? content : null
|
||||
}
|
||||
|
||||
export function extractBlocksFromHtml(html: string): ExtractedBlock[] {
|
||||
const blocks: ExtractedBlock[] = []
|
||||
const regex = /<(?:p|h[1-6]|blockquote|li)[^>]*data-id="([^"]+)"[^>]*>([\s\S]*?)<\/(?:p|h[1-6]|blockquote|li)>/gi
|
||||
|
||||
25
memento-note/lib/editor/block-at-drag-handle.ts
Normal file
25
memento-note/lib/editor/block-at-drag-handle.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
export const BLOCK_DRAG_HANDLE_ID = 'notion-block-drag-handle'
|
||||
export const BLOCK_DRAG_HANDLE_WIDTH = 20
|
||||
|
||||
/** Même logique de résolution de bloc que tiptap-extension-global-drag-handle */
|
||||
export function resolveBlockAtDragHandle(editor: Editor): { node: PMNode; pos: number } | null {
|
||||
const handle = document.getElementById(BLOCK_DRAG_HANDLE_ID)
|
||||
if (!handle || editor.isDestroyed) return null
|
||||
|
||||
const rect = handle.getBoundingClientRect()
|
||||
const coords = editor.view.posAtCoords({
|
||||
left: rect.right + 50 + BLOCK_DRAG_HANDLE_WIDTH,
|
||||
top: rect.top + rect.height / 2,
|
||||
})
|
||||
if (coords?.pos == null) return null
|
||||
|
||||
const $pos = editor.state.doc.resolve(coords.pos)
|
||||
const blockPos = $pos.depth > 1 ? $pos.before($pos.depth) : coords.pos
|
||||
const node = editor.state.doc.nodeAt(blockPos)
|
||||
if (!node) return null
|
||||
|
||||
return { node, pos: blockPos }
|
||||
}
|
||||
122
memento-note/lib/editor/block-reference-id.ts
Normal file
122
memento-note/lib/editor/block-reference-id.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
const ID_ATTR = 'data-id'
|
||||
|
||||
const BLOCK_TYPES_WITH_ID = new Set([
|
||||
'paragraph',
|
||||
'heading',
|
||||
'blockquote',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'taskList',
|
||||
'codeBlock',
|
||||
])
|
||||
|
||||
export function generateBlockId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
function readNodeId(node: PMNode | null | undefined): string | null {
|
||||
const id = node?.attrs[ID_ATTR]
|
||||
return typeof id === 'string' && id.length > 0 ? id : null
|
||||
}
|
||||
|
||||
function findDataIdInSubtree(node: PMNode): string | null {
|
||||
const direct = readNodeId(node)
|
||||
if (direct) return direct
|
||||
|
||||
let found: string | null = null
|
||||
node.descendants((child) => {
|
||||
if (found) return false
|
||||
found = readNodeId(child)
|
||||
return !found
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
function findDataIdFromDom(editor: Editor, blockPos: number): string | null {
|
||||
const dom = editor.view.nodeDOM(blockPos)
|
||||
if (!(dom instanceof HTMLElement)) return null
|
||||
|
||||
const direct = dom.getAttribute(ID_ATTR)
|
||||
if (direct) return direct
|
||||
|
||||
const nested = dom.querySelector(`[${ID_ATTR}]`)
|
||||
const nestedId = nested?.getAttribute(ID_ATTR)
|
||||
return nestedId || null
|
||||
}
|
||||
|
||||
function findTextBlockToAssign(
|
||||
editor: Editor,
|
||||
blockPos: number,
|
||||
blockNode: PMNode,
|
||||
): { pos: number; node: PMNode } | null {
|
||||
if (BLOCK_TYPES_WITH_ID.has(blockNode.type.name)) {
|
||||
return { pos: blockPos, node: blockNode }
|
||||
}
|
||||
|
||||
let target: { pos: number; node: PMNode } | null = null
|
||||
editor.state.doc.nodesBetween(blockPos, blockPos + blockNode.nodeSize, (node, pos) => {
|
||||
if (target) return false
|
||||
if (node.isTextblock && BLOCK_TYPES_WITH_ID.has(node.type.name)) {
|
||||
target = { pos, node }
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return target
|
||||
}
|
||||
|
||||
/** Cherche un data-id sur le bloc résolu (nœud, descendants, ancêtres, DOM). */
|
||||
export function findBlockReferenceId(
|
||||
editor: Editor,
|
||||
blockPos: number,
|
||||
blockNode: PMNode | null,
|
||||
): string | null {
|
||||
const node = blockNode ?? (blockPos >= 0 ? editor.state.doc.nodeAt(blockPos) : null)
|
||||
if (!node) return null
|
||||
|
||||
const fromSubtree = findDataIdInSubtree(node)
|
||||
if (fromSubtree) return fromSubtree
|
||||
|
||||
const $pos = editor.state.doc.resolve(Math.min(blockPos + 1, editor.state.doc.content.size))
|
||||
for (let depth = $pos.depth; depth > 0; depth--) {
|
||||
const id = readNodeId($pos.node(depth))
|
||||
if (id) return id
|
||||
}
|
||||
|
||||
return findDataIdFromDom(editor, blockPos)
|
||||
}
|
||||
|
||||
/** Assigne un data-id si absent, retourne l'id utilisable pour la référence. */
|
||||
export function ensureBlockReferenceId(
|
||||
editor: Editor,
|
||||
blockPos: number,
|
||||
blockNode: PMNode | null,
|
||||
): string | null {
|
||||
const existing = findBlockReferenceId(editor, blockPos, blockNode)
|
||||
if (existing) return existing
|
||||
|
||||
const node = blockNode ?? (blockPos >= 0 ? editor.state.doc.nodeAt(blockPos) : null)
|
||||
if (!node || blockPos < 0) return null
|
||||
|
||||
const target = findTextBlockToAssign(editor, blockPos, node)
|
||||
if (!target) return null
|
||||
|
||||
const newId = generateBlockId()
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setNodeMarkup(target.pos, undefined, {
|
||||
...target.node.attrs,
|
||||
[ID_ATTR]: newId,
|
||||
}),
|
||||
)
|
||||
return newId
|
||||
}
|
||||
30
memento-note/lib/editor/copy-text-to-clipboard.ts
Normal file
30
memento-note/lib/editor/copy-text-to-clipboard.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/** Copie texte — writeText async + repli synchrone execCommand. */
|
||||
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return false
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// repli ci-dessous
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.setAttribute('readonly', '')
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.left = '-9999px'
|
||||
ta.style.top = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
return ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
100
memento-note/lib/editor/database-block-types.ts
Normal file
100
memento-note/lib/editor/database-block-types.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export type DatabaseAuthor = { id: string; name: string }
|
||||
|
||||
export type DatabaseBook = {
|
||||
id: string
|
||||
title: string
|
||||
author: string
|
||||
cover: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export type DatabaseView = 'table' | 'card'
|
||||
|
||||
export type DatabaseBlockData = {
|
||||
dbId: string
|
||||
dbView: DatabaseView
|
||||
dbAuthors: DatabaseAuthor[]
|
||||
dbBooks: DatabaseBook[]
|
||||
}
|
||||
|
||||
const DEFAULT_COVERS = [
|
||||
'https://images.unsplash.com/photo-1543002588-bfa74002ed7e?auto=format&fit=crop&q=80&w=400',
|
||||
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&q=80&w=400',
|
||||
'https://images.unsplash.com/photo-1506318137071-a8e063b4bec0?auto=format&fit=crop&q=80&w=400',
|
||||
'https://images.unsplash.com/photo-1512820790803-83ca734da794?auto=format&fit=crop&q=80&w=400',
|
||||
]
|
||||
|
||||
export function randomDefaultCover(): string {
|
||||
return DEFAULT_COVERS[Math.floor(Math.random() * DEFAULT_COVERS.length)]
|
||||
}
|
||||
|
||||
export function createDefaultDatabaseBlockData(): DatabaseBlockData {
|
||||
return {
|
||||
dbId: `authors-works-${typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID().slice(0, 8) : Date.now()}`,
|
||||
dbView: 'table',
|
||||
dbAuthors: [
|
||||
{ id: 'a1', name: 'Jules Verne' },
|
||||
{ id: 'a2', name: 'Liu Cixin' },
|
||||
],
|
||||
dbBooks: [
|
||||
{
|
||||
id: 'bk1',
|
||||
title: 'Twenty Thousand Leagues Under The Sea',
|
||||
author: 'Jules Verne',
|
||||
cover: DEFAULT_COVERS[0],
|
||||
tag: 'Aventure',
|
||||
},
|
||||
{
|
||||
id: 'bk2',
|
||||
title: 'The Three-Body Problem',
|
||||
author: 'Liu Cixin',
|
||||
cover: DEFAULT_COVERS[1],
|
||||
tag: 'Hard SF',
|
||||
},
|
||||
{
|
||||
id: 'bk3',
|
||||
title: 'The Wandering Earth',
|
||||
author: 'Liu Cixin',
|
||||
cover: DEFAULT_COVERS[2],
|
||||
tag: 'SF Spatial',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeDatabaseBlockData(data: DatabaseBlockData): {
|
||||
dbId: string
|
||||
dbView: DatabaseView
|
||||
dbAuthorsJson: string
|
||||
dbBooksJson: string
|
||||
} {
|
||||
return {
|
||||
dbId: data.dbId,
|
||||
dbView: data.dbView,
|
||||
dbAuthorsJson: JSON.stringify(data.dbAuthors),
|
||||
dbBooksJson: JSON.stringify(data.dbBooks),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDatabaseBlockAttrs(attrs: {
|
||||
dbId?: string
|
||||
dbView?: string
|
||||
dbAuthorsJson?: string
|
||||
dbBooksJson?: string
|
||||
}): DatabaseBlockData {
|
||||
const fallback = createDefaultDatabaseBlockData()
|
||||
let authors = fallback.dbAuthors
|
||||
let books = fallback.dbBooks
|
||||
try {
|
||||
if (attrs.dbAuthorsJson) authors = JSON.parse(attrs.dbAuthorsJson)
|
||||
} catch { /* keep fallback */ }
|
||||
try {
|
||||
if (attrs.dbBooksJson) books = JSON.parse(attrs.dbBooksJson)
|
||||
} catch { /* keep fallback */ }
|
||||
return {
|
||||
dbId: attrs.dbId || fallback.dbId,
|
||||
dbView: attrs.dbView === 'card' ? 'card' : 'table',
|
||||
dbAuthors: Array.isArray(authors) ? authors : fallback.dbAuthors,
|
||||
dbBooks: Array.isArray(books) ? books : fallback.dbBooks,
|
||||
}
|
||||
}
|
||||
19
memento-note/lib/editor/empty-paragraph-at-selection.ts
Normal file
19
memento-note/lib/editor/empty-paragraph-at-selection.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
|
||||
function isEmptyParagraph(node: PMNode): boolean {
|
||||
if (node.type.name !== 'paragraph') return false
|
||||
return node.textContent.trim() === '' && node.content.size <= 1
|
||||
}
|
||||
|
||||
/** Paragraphe vide sous le curseur (critère US-3 Smart Paste). */
|
||||
export function getEmptyParagraphAtSelection(
|
||||
state: EditorState,
|
||||
): { pos: number; node: PMNode } | null {
|
||||
const { $from, empty } = state.selection
|
||||
if (!empty) return null
|
||||
if (!isEmptyParagraph($from.parent)) return null
|
||||
|
||||
const pos = $from.before($from.depth)
|
||||
return { pos, node: $from.parent }
|
||||
}
|
||||
17
memento-note/lib/editor/global-drag-handle-extension.ts
Normal file
17
memento-note/lib/editor/global-drag-handle-extension.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import GlobalDragHandle from 'tiptap-extension-global-drag-handle'
|
||||
import AutoJoiner from 'tiptap-extension-auto-joiner'
|
||||
import { BLOCK_DRAG_HANDLE_ID, BLOCK_DRAG_HANDLE_WIDTH } from './block-at-drag-handle'
|
||||
|
||||
/** Extensions drag handle Novel / Notion — une poignée globale, position fixed */
|
||||
export const globalDragHandleExtensions = [
|
||||
GlobalDragHandle.configure({
|
||||
dragHandleWidth: BLOCK_DRAG_HANDLE_WIDTH,
|
||||
scrollTreshold: 100,
|
||||
dragHandleSelector: `#${BLOCK_DRAG_HANDLE_ID}`,
|
||||
excludedTags: [],
|
||||
customNodes: [],
|
||||
}),
|
||||
AutoJoiner.configure({
|
||||
elementsToJoin: ['bulletList', 'orderedList'],
|
||||
}),
|
||||
]
|
||||
67
memento-note/lib/editor/parse-block-reference.ts
Normal file
67
memento-note/lib/editor/parse-block-reference.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export type ParsedBlockReference = {
|
||||
sourceNoteId: string
|
||||
blockId: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
export type StoredBlockReference = ParsedBlockReference & {
|
||||
blockContent?: string
|
||||
sourceNoteTitle?: string
|
||||
}
|
||||
|
||||
export const LAST_BLOCK_REF_SESSION_KEY = 'memento:lastBlockRef'
|
||||
|
||||
/** Référence bloc copiée via « Copier la référence » : /home?openNote=…#block-… */
|
||||
export function parseBlockReferenceFromText(text: string): ParsedBlockReference | null {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed || trimmed.includes('\n')) return null
|
||||
|
||||
const match = trimmed.match(/openNote=([^&#\s]+)(?:[^#]*?)#block-([a-zA-Z0-9_-]+)/i)
|
||||
if (!match) return null
|
||||
|
||||
try {
|
||||
return {
|
||||
sourceNoteId: decodeURIComponent(match[1]),
|
||||
blockId: match[2],
|
||||
raw: trimmed,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberBlockReference(
|
||||
raw: string,
|
||||
extras?: { blockContent?: string; sourceNoteTitle?: string },
|
||||
) {
|
||||
if (typeof sessionStorage === 'undefined') return
|
||||
const parsed = parseBlockReferenceFromText(raw)
|
||||
if (!parsed) return
|
||||
const payload: StoredBlockReference = {
|
||||
...parsed,
|
||||
...(extras?.blockContent ? { blockContent: extras.blockContent } : {}),
|
||||
...(extras?.sourceNoteTitle ? { sourceNoteTitle: extras.sourceNoteTitle } : {}),
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(LAST_BLOCK_REF_SESSION_KEY, JSON.stringify(payload))
|
||||
} catch {
|
||||
// quota / private mode
|
||||
}
|
||||
}
|
||||
|
||||
export function recallLastBlockReference(): StoredBlockReference | null {
|
||||
if (typeof sessionStorage === 'undefined') return null
|
||||
try {
|
||||
const stored = sessionStorage.getItem(LAST_BLOCK_REF_SESSION_KEY)
|
||||
if (!stored) return null
|
||||
if (stored.startsWith('{')) {
|
||||
const payload = JSON.parse(stored) as StoredBlockReference
|
||||
if (payload?.sourceNoteId && payload?.blockId && payload?.raw) return payload
|
||||
return null
|
||||
}
|
||||
const parsed = parseBlockReferenceFromText(stored)
|
||||
return parsed ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
39
memento-note/lib/editor/smart-paste-extension.ts
Normal file
39
memento-note/lib/editor/smart-paste-extension.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
export type SmartPasteHandler = (view: EditorView, event: ClipboardEvent) => boolean
|
||||
|
||||
export const smartPastePluginKey = new PluginKey('smartPaste')
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Storage {
|
||||
smartPaste: {
|
||||
onPaste: SmartPasteHandler | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SmartPasteExtension = Extension.create({
|
||||
name: 'smartPaste',
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
onPaste: null as SmartPasteHandler | null,
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const storage = this.storage
|
||||
return [
|
||||
new Plugin({
|
||||
key: smartPastePluginKey,
|
||||
props: {
|
||||
handlePaste(view, event) {
|
||||
return storage.onPaste?.(view, event) ?? false
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
18
memento-note/lib/note-peek-sync.ts
Normal file
18
memento-note/lib/note-peek-sync.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Ouvre la note source en aperçu split (panneau gauche) sans changer d’onglet. */
|
||||
export const NOTE_PEEK_OPEN_EVENT = 'memento-note-peek-open'
|
||||
export const NOTE_PEEK_CLOSE_EVENT = 'memento-note-peek-close'
|
||||
|
||||
export type NotePeekOpenDetail = {
|
||||
noteId: string
|
||||
blockId?: string
|
||||
}
|
||||
|
||||
export function openNotePeek(detail: NotePeekOpenDetail) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent(NOTE_PEEK_OPEN_EVENT, { detail }))
|
||||
}
|
||||
|
||||
export function closeNotePeek() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent(NOTE_PEEK_CLOSE_EVENT))
|
||||
}
|
||||
@@ -3,6 +3,25 @@ import type { Note } from '@/lib/types'
|
||||
const MD_IMG = /!\[[^\]]*\]\(([^)\s]+)\)/g
|
||||
const HTML_IMG = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi
|
||||
|
||||
/** SSR-safe HTML entity decode so excerpt text matches server and client. */
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/(?:�*39;|')/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, code: string) => {
|
||||
const n = Number(code)
|
||||
return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : `&#${code};`
|
||||
})
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => {
|
||||
const n = parseInt(hex, 16)
|
||||
return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : `&#x${hex};`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text preview for list view (light markdown stripping).
|
||||
*/
|
||||
@@ -58,6 +77,7 @@ export function getNotePlainExcerpt(note: Note, maxLen = 240): string {
|
||||
let raw = note.content || ''
|
||||
raw = raw.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
||||
raw = raw.replace(/<[^>]+>/g, ' ')
|
||||
raw = decodeHtmlEntities(raw)
|
||||
return stripMarkdownPreview(raw, maxLen)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { NotesLayoutMode, NotesViewType } from '@/components/notes-list-views'
|
||||
import type { NotesLayoutMode } from '@/components/notes-list-views'
|
||||
|
||||
export const NOTES_LAYOUT_COOKIE = 'memento-notes-layout'
|
||||
export const NOTES_VIEW_TYPE_COOKIE = 'memento-notes-view-type'
|
||||
export const NOTES_LAYOUT_STORAGE_KEY = 'memento-notes-layout'
|
||||
export const NOTES_VIEW_TYPE_STORAGE_KEY = 'memento-notes-view-type'
|
||||
|
||||
const LAYOUT_VALUES: NotesLayoutMode[] = ['grid', 'list', 'table', 'kanban', 'gallery']
|
||||
const VIEW_TYPE_VALUES: NotesViewType[] = ['notes', 'tasks']
|
||||
|
||||
export function parseNotesLayoutMode(value: string | undefined | null): NotesLayoutMode {
|
||||
if (value && (LAYOUT_VALUES as string[]).includes(value)) {
|
||||
@@ -17,19 +14,8 @@ export function parseNotesLayoutMode(value: string | undefined | null): NotesLay
|
||||
return 'list'
|
||||
}
|
||||
|
||||
export function parseNotesViewType(value: string | undefined | null): NotesViewType {
|
||||
if (value && (VIEW_TYPE_VALUES as string[]).includes(value)) return value as NotesViewType
|
||||
return 'notes'
|
||||
}
|
||||
|
||||
export function setNotesLayoutPreference(layout: NotesLayoutMode) {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(NOTES_LAYOUT_STORAGE_KEY, layout)
|
||||
document.cookie = `${NOTES_LAYOUT_COOKIE}=${layout}; path=/; max-age=31536000; SameSite=Lax`
|
||||
}
|
||||
|
||||
export function setNotesViewTypePreference(viewType: NotesViewType) {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(NOTES_VIEW_TYPE_STORAGE_KEY, viewType)
|
||||
document.cookie = `${NOTES_VIEW_TYPE_COOKIE}=${viewType}; path=/; max-age=31536000; SameSite=Lax`
|
||||
}
|
||||
|
||||
10
memento-note/lib/use-hydrated.ts
Normal file
10
memento-note/lib/use-hydrated.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
/** True only after the client has hydrated (false on server + first client paint). */
|
||||
export function useHydrated(): boolean {
|
||||
return useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
@@ -74,7 +74,10 @@
|
||||
"noReminders": "No active reminders.",
|
||||
"documents": "Documents",
|
||||
"searchNotebooksPlaceholder": "Search notebooks…",
|
||||
"clearSearch": "Clear search"
|
||||
"clearSearch": "Clear search",
|
||||
"insightsPanelBody": "Semantic map of your notes: thematic clusters, bridge notes, and connection suggestions.",
|
||||
"revisionPanelBody": "Review flashcards with the SM-2 algorithm. Decks are generated from your notes.",
|
||||
"backToNotebooks": "Back to notebooks"
|
||||
},
|
||||
"notes": {
|
||||
"title": "Notes",
|
||||
@@ -2347,6 +2350,8 @@
|
||||
"slashDividerDesc": "Horizontal separator",
|
||||
"slashTable": "Table",
|
||||
"slashTableDesc": "Insert a simple grid",
|
||||
"slashDatabase": "Database",
|
||||
"slashDatabaseDesc": "Inline authors & works database",
|
||||
"slashDiagram": "Diagram",
|
||||
"slashDiagramDesc": "Generate a flow or mindmap",
|
||||
"slashSlides": "Presentation",
|
||||
@@ -3310,5 +3315,76 @@
|
||||
"uploadError": "Upload error",
|
||||
"uploadFailed": "Upload failed",
|
||||
"uploading": "Uploading..."
|
||||
},
|
||||
"blockAction": {
|
||||
"delete": "Delete",
|
||||
"duplicate": "Duplicate",
|
||||
"turnInto": "Turn into",
|
||||
"turnInto_heading1": "Heading 1",
|
||||
"turnInto_heading2": "Heading 2",
|
||||
"turnInto_heading3": "Heading 3",
|
||||
"turnInto_bulletList": "Bullet List",
|
||||
"turnInto_orderedList": "Numbered List",
|
||||
"turnInto_taskList": "Task List",
|
||||
"turnInto_blockquote": "Quote",
|
||||
"turnInto_codeBlock": "Code Block",
|
||||
"turnInto_database": "Database",
|
||||
"copyRef": "Copy block reference",
|
||||
"copied": "Reference copied!",
|
||||
"copyRefFailed": "Could not copy block reference",
|
||||
"copyRefNoNote": "Save the note before copying a block reference",
|
||||
"copyRefUnsupported": "This block type cannot be referenced yet"
|
||||
},
|
||||
"smartPaste": {
|
||||
"prompt": "Paste this block reference as:",
|
||||
"liveBlock": "Connected block (live)",
|
||||
"plainLink": "Plain text / link",
|
||||
"unknownNote": "Untitled note"
|
||||
},
|
||||
"liveBlock": {
|
||||
"connectedNote": "Connected note",
|
||||
"sourceDisconnected": "Source disconnected",
|
||||
"statusLive": "LIVE",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusDisconnected": "DISCONNECTED",
|
||||
"detachLink": "Unlink block",
|
||||
"detachHelp": "Convert this block to normal text in this note",
|
||||
"openSource": "Open",
|
||||
"removeBlock": "Remove block",
|
||||
"emptyContent": "(empty block)"
|
||||
},
|
||||
"notePeek": {
|
||||
"label": "Linked note",
|
||||
"panelLabel": "Source note preview",
|
||||
"close": "Close preview",
|
||||
"openFully": "Open full screen",
|
||||
"openFullyHelp": "Replace the current note with this one",
|
||||
"readOnlyHint": "Read-only preview — open full screen to edit.",
|
||||
"loadFailed": "Could not open the linked note preview."
|
||||
},
|
||||
"databaseBlock": {
|
||||
"title": "Authors & Works",
|
||||
"hint": "Inline database — data is stored in this note.",
|
||||
"viewTable": "Table",
|
||||
"viewCards": "Cards",
|
||||
"colAuthor": "Author",
|
||||
"colWorks": "Works",
|
||||
"colRollup": "Rollup",
|
||||
"noLinkedWorks": "No linked works",
|
||||
"deleteShort": "Del",
|
||||
"deleteCard": "Remove work",
|
||||
"addAuthor": "Add author",
|
||||
"authorPlaceholder": "Author name…",
|
||||
"createAuthor": "Create",
|
||||
"addWork": "Add a work",
|
||||
"bookTitlePlaceholder": "Work title…",
|
||||
"selectAuthor": "Select author…",
|
||||
"tagPlaceholder": "Genre / tag…",
|
||||
"coverPlaceholder": "Cover image URL (optional)",
|
||||
"insertWork": "Insert work",
|
||||
"defaultTag": "General",
|
||||
"worksBase": "Works database",
|
||||
"storedCount": "{count} work(s) stored",
|
||||
"insertFailed": "Could not insert the database block. Try again on an empty line."
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,10 @@
|
||||
"noReminders": "Aucun rappel actif.",
|
||||
"documents": "Documents",
|
||||
"searchNotebooksPlaceholder": "Rechercher un carnet…",
|
||||
"clearSearch": "Effacer la recherche"
|
||||
"clearSearch": "Effacer la recherche",
|
||||
"insightsPanelBody": "Cartographie sémantique de vos notes : clusters thématiques, notes-ponts et suggestions de connexion.",
|
||||
"revisionPanelBody": "Révisez vos flashcards avec l'algorithme SM-2. Les decks sont générés depuis vos notes.",
|
||||
"backToNotebooks": "Retour aux carnets"
|
||||
},
|
||||
"notes": {
|
||||
"title": "Notes",
|
||||
@@ -2351,6 +2354,8 @@
|
||||
"slashDividerDesc": "Séparateur horizontal",
|
||||
"slashTable": "Tableau",
|
||||
"slashTableDesc": "Insérer un tableau simple",
|
||||
"slashDatabase": "Base de données",
|
||||
"slashDatabaseDesc": "Base inline auteurs et œuvres",
|
||||
"slashDiagram": "Diagramme",
|
||||
"slashDiagramDesc": "Générer un flux ou une carte mentale",
|
||||
"slashSlides": "Présentation",
|
||||
@@ -3314,5 +3319,76 @@
|
||||
"uploadError": "Erreur de téléchargement",
|
||||
"uploadFailed": "Échec du téléchargement",
|
||||
"uploading": "Téléchargement..."
|
||||
},
|
||||
"blockAction": {
|
||||
"delete": "Supprimer",
|
||||
"duplicate": "Dupliquer",
|
||||
"turnInto": "Transformer en",
|
||||
"turnInto_heading1": "Titre 1",
|
||||
"turnInto_heading2": "Titre 2",
|
||||
"turnInto_heading3": "Titre 3",
|
||||
"turnInto_bulletList": "Liste à puces",
|
||||
"turnInto_orderedList": "Liste numérotée",
|
||||
"turnInto_taskList": "Liste de tâches",
|
||||
"turnInto_blockquote": "Citation",
|
||||
"turnInto_codeBlock": "Bloc de code",
|
||||
"turnInto_database": "Base de données",
|
||||
"copyRef": "Copier la référence du bloc",
|
||||
"copied": "Référence copiée !",
|
||||
"copyRefFailed": "Impossible de copier la référence du bloc",
|
||||
"copyRefNoNote": "Enregistrez la note avant de copier une référence de bloc",
|
||||
"copyRefUnsupported": "Ce type de bloc ne peut pas encore être référencé"
|
||||
},
|
||||
"smartPaste": {
|
||||
"prompt": "Coller cette référence de bloc en tant que :",
|
||||
"liveBlock": "Bloc connecté (live)",
|
||||
"plainLink": "Texte / lien simple",
|
||||
"unknownNote": "Note sans titre"
|
||||
},
|
||||
"liveBlock": {
|
||||
"connectedNote": "Note connectée",
|
||||
"sourceDisconnected": "Source déconnectée",
|
||||
"statusLive": "LIVE",
|
||||
"statusOffline": "HORS-LIGNE",
|
||||
"statusDisconnected": "DÉCONNECTÉ",
|
||||
"detachLink": "Décharger le lien",
|
||||
"detachHelp": "Transformer ce bloc en texte normal dans cette note",
|
||||
"openSource": "Ouvrir",
|
||||
"removeBlock": "Supprimer le bloc",
|
||||
"emptyContent": "(bloc vide)"
|
||||
},
|
||||
"notePeek": {
|
||||
"label": "Note liée",
|
||||
"panelLabel": "Aperçu de la note source",
|
||||
"close": "Fermer l'aperçu",
|
||||
"openFully": "Ouvrir en plein écran",
|
||||
"openFullyHelp": "Remplacer la note courante par cette note",
|
||||
"readOnlyHint": "Aperçu en lecture seule — ouvrez en plein écran pour modifier.",
|
||||
"loadFailed": "Impossible d'ouvrir l'aperçu de la note liée."
|
||||
},
|
||||
"databaseBlock": {
|
||||
"title": "Auteurs & Œuvres",
|
||||
"hint": "Base inline — les données sont enregistrées dans cette note.",
|
||||
"viewTable": "Tableau",
|
||||
"viewCards": "Fiches",
|
||||
"colAuthor": "Auteur",
|
||||
"colWorks": "Œuvres",
|
||||
"colRollup": "Total",
|
||||
"noLinkedWorks": "Aucune œuvre liée",
|
||||
"deleteShort": "Suppr.",
|
||||
"deleteCard": "Retirer l'œuvre",
|
||||
"addAuthor": "Ajouter un auteur",
|
||||
"authorPlaceholder": "Nom de l'auteur…",
|
||||
"createAuthor": "Créer",
|
||||
"addWork": "Ajouter une œuvre",
|
||||
"bookTitlePlaceholder": "Titre de l'œuvre…",
|
||||
"selectAuthor": "Choisir un auteur…",
|
||||
"tagPlaceholder": "Genre / étiquette…",
|
||||
"coverPlaceholder": "URL de couverture (optionnel)",
|
||||
"insertWork": "Insérer l'œuvre",
|
||||
"defaultTag": "Général",
|
||||
"worksBase": "Base d'œuvres",
|
||||
"storedCount": "{count} œuvre(s) enregistrée(s)",
|
||||
"insertFailed": "Impossible d'insérer le bloc base de données. Réessayez sur une ligne vide."
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
{
|
||||
source: '/reminders',
|
||||
destination: '/?reminders=1',
|
||||
destination: '/home?reminders=1',
|
||||
permanent: false,
|
||||
},
|
||||
]
|
||||
|
||||
610
memento-note/package-lock.json
generated
610
memento-note/package-lock.json
generated
@@ -40,9 +40,13 @@
|
||||
"@stripe/react-stripe-js": "^6.3.0",
|
||||
"@stripe/stripe-js": "^9.5.0",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tiptap/extension-collaboration": "^3.23.6",
|
||||
"@tiptap/extension-color": "^3.22.5",
|
||||
"@tiptap/extension-drag-handle": "^3.23.6",
|
||||
"@tiptap/extension-drag-handle-react": "^3.23.6",
|
||||
"@tiptap/extension-highlight": "^3.22.5",
|
||||
"@tiptap/extension-image": "^3.22.5",
|
||||
"@tiptap/extension-node-range": "^3.23.6",
|
||||
"@tiptap/extension-placeholder": "^3.22.5",
|
||||
"@tiptap/extension-subscript": "^3.22.5",
|
||||
"@tiptap/extension-superscript": "^3.22.5",
|
||||
@@ -59,6 +63,7 @@
|
||||
"@tiptap/react": "^3.22.5",
|
||||
"@tiptap/starter-kit": "^3.22.5",
|
||||
"@tiptap/suggestion": "^3.22.5",
|
||||
"@tiptap/y-tiptap": "^3.0.3",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"ai": "^6.0.23",
|
||||
@@ -111,7 +116,11 @@
|
||||
"stripe": "^22.1.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tinyld": "^1.3.4",
|
||||
"tiptap-extension-auto-joiner": "^0.1.3",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"vazirmatn": "^33.0.3",
|
||||
"y-protocols": "^1.0.7",
|
||||
"yjs": "^13.6.30",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3765,350 +3774,11 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3",
|
||||
"is-glob": "^4.0.3",
|
||||
"node-addon-api": "^7.0.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher-android-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-x64": "2.5.6",
|
||||
"@parcel/watcher-freebsd-x64": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-musl": "2.5.6",
|
||||
"@parcel/watcher-win32-arm64": "2.5.6",
|
||||
"@parcel/watcher-win32-ia32": "2.5.6",
|
||||
"@parcel/watcher-win32-x64": "2.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-android-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-ia32": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
|
||||
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
@@ -4142,14 +3812,14 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
||||
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
||||
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -4163,14 +3833,14 @@
|
||||
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
||||
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
||||
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "5.22.0",
|
||||
@@ -4182,7 +3852,7 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
||||
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "5.22.0"
|
||||
@@ -7946,6 +7616,22 @@
|
||||
"@tiptap/pm": "3.22.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-collaboration": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.23.6.tgz",
|
||||
"integrity": "sha512-OvPtPUWH3uormtQ8Ei/di6h/gv9ttv+IDy8zz1t4hrP2XLQHgpd1yv8/bnwz0jsZhsl+4Km2Wb0H5oS3Rh5+cA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6",
|
||||
"@tiptap/y-tiptap": "^3.0.2",
|
||||
"yjs": "^13"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-color": {
|
||||
"version": "3.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.22.5.tgz",
|
||||
@@ -7972,6 +7658,43 @@
|
||||
"@tiptap/core": "3.22.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-drag-handle": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.23.6.tgz",
|
||||
"integrity": "sha512-+IJgVq7GDb4yDft5Dr8fW61qqdyeO30QgaS12GlvX+mfaurPOUtMhnr9POX1Vb4QMogxkwXD6pA0RA+AYkI3qw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/extension-collaboration": "3.23.6",
|
||||
"@tiptap/extension-node-range": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6",
|
||||
"@tiptap/y-tiptap": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-drag-handle-react": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle-react/-/extension-drag-handle-react-3.23.6.tgz",
|
||||
"integrity": "sha512-ZScNZMinmjFNRgPNjvz0+ZHZXbM/s0JgYv14u/JptiGK7q63pSFDACL4mpRvx7aYwkkLs6egOOux1DVvWqhNHg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-drag-handle": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6",
|
||||
"@tiptap/react": "3.23.6",
|
||||
"react": "^16.8 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz",
|
||||
@@ -8150,6 +7873,20 @@
|
||||
"@tiptap/extension-list": "3.22.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-node-range": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.23.6.tgz",
|
||||
"integrity": "sha512-S+EN8HDnXOFxtamtkAkDY++B6jnVfjNBBHtDbO6i4as7PUeRY/JJIPPwlyL/cZULfP+ePYyr4eIVxFSMQxFXtg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.6",
|
||||
"@tiptap/pm": "3.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz",
|
||||
@@ -8489,6 +8226,26 @@
|
||||
"@tiptap/pm": "3.22.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/y-tiptap": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.3.tgz",
|
||||
"integrity": "sha512-8UvuV4lTisCE9cMTc/X8kRyTn9edUO7Kball0I6wb17VwZSjNDfh/YKtP4O5vcPawEzFHQIvZGq/k1h37kAf0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.100"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prosemirror-model": "^1.7.1",
|
||||
"prosemirror-state": "^1.2.3",
|
||||
"prosemirror-view": "^1.9.10",
|
||||
"y-protocols": "^1.0.1",
|
||||
"yjs": "^13.5.38"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "25.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
|
||||
@@ -8952,6 +8709,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -8961,6 +8719,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -14232,6 +13991,16 @@
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic.js": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
@@ -14633,6 +14402,27 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lib0": {
|
||||
"version": "0.2.117",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
|
||||
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isomorphic.js": "^0.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||
"0gentesthtml": "bin/gentesthtml.js",
|
||||
"0serve": "bin/0serve.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
@@ -16336,15 +16126,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/node-exports-info": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
||||
@@ -16853,7 +16634,7 @@
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
@@ -16872,7 +16653,7 @@
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -17052,7 +16833,7 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -19090,6 +18871,18 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiptap-extension-auto-joiner": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tiptap-extension-auto-joiner/-/tiptap-extension-auto-joiner-0.1.3.tgz",
|
||||
"integrity": "sha512-nY3aKeCpVb2WjjVEZkLtEqxsK3KU1zGioyglMhK1sUFNjKDccOfRyz/YDKrHRAVsKJPGnk2A8VA1827iGEAXWQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiptap-extension-global-drag-handle": {
|
||||
"version": "0.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tiptap-extension-global-drag-handle/-/tiptap-extension-global-drag-handle-0.1.18.tgz",
|
||||
"integrity": "sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.28",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz",
|
||||
@@ -19920,24 +19713,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -19953,15 +19728,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
@@ -19975,45 +19741,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/sass": {
|
||||
"version": "1.100.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz",
|
||||
"integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"immutable": "^5.1.5",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"sass": "sass.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/vite": {
|
||||
"version": "8.0.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
|
||||
@@ -20442,6 +20169,26 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y-protocols": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
|
||||
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.85"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
@@ -20488,6 +20235,23 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.30",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz",
|
||||
"integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.99"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -61,9 +61,13 @@
|
||||
"@stripe/react-stripe-js": "^6.3.0",
|
||||
"@stripe/stripe-js": "^9.5.0",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tiptap/extension-collaboration": "^3.23.6",
|
||||
"@tiptap/extension-color": "^3.22.5",
|
||||
"@tiptap/extension-drag-handle": "^3.23.6",
|
||||
"@tiptap/extension-drag-handle-react": "^3.23.6",
|
||||
"@tiptap/extension-highlight": "^3.22.5",
|
||||
"@tiptap/extension-image": "^3.22.5",
|
||||
"@tiptap/extension-node-range": "^3.23.6",
|
||||
"@tiptap/extension-placeholder": "^3.22.5",
|
||||
"@tiptap/extension-subscript": "^3.22.5",
|
||||
"@tiptap/extension-superscript": "^3.22.5",
|
||||
@@ -80,6 +84,7 @@
|
||||
"@tiptap/react": "^3.22.5",
|
||||
"@tiptap/starter-kit": "^3.22.5",
|
||||
"@tiptap/suggestion": "^3.22.5",
|
||||
"@tiptap/y-tiptap": "^3.0.3",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
"ai": "^6.0.23",
|
||||
@@ -132,7 +137,11 @@
|
||||
"stripe": "^22.1.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tinyld": "^1.3.4",
|
||||
"tiptap-extension-auto-joiner": "^0.1.3",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"vazirmatn": "^33.0.3",
|
||||
"y-protocols": "^1.0.7",
|
||||
"yjs": "^13.6.30",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Reference in New Issue
Block a user