feat: publication IA (magazine/brief/essay) + fixes critique
Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
This commit is contained in:
@@ -2,24 +2,68 @@
|
||||
"/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": 1779911218415,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/106b4ed1-1305-459a-bc51-a868f74ae8ed.jsonl": 1779663104529,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/2c9092f9-a673-46f8-83e3-f581c163efad.jsonl": 1779603239708,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/2f857168-9a7e-4b49-bd58-9c03c7323e3a.jsonl": 1779698370371,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/35f87c99-cea4-4cc2-8061-7327b65be5c8.jsonl": 1779629959373,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/43896b0f-286f-40ef-afcc-8ab38ac791a1.jsonl": 1779632608794,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/456c9498-caf4-4fb8-974f-84fd08825112.jsonl": 1779639839217,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/4a15f5aa-adba-4448-a04c-5116d3963ae0.jsonl": 1779665958349,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/5a3ecd13-c94d-4f95-8603-2196fc3dc2ff.jsonl": 1779910037067,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/5de1ff5f-8122-44dd-abec-ec92171082ff.jsonl": 1779612012789,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/6145ac4f-4bd4-4873-9fa5-8567e6dbeeab.jsonl": 1779692411702,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/8f3177e4-49d3-4178-a58f-cceb8cca7fc1.jsonl": 1779737949557,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/921b4981-f949-4855-ba62-84e9c0db5ee8.jsonl": 1779647030219,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/98014b6d-83e5-44c8-b808-f98eb29fb4a1.jsonl": 1779619178702,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/a7a904f4-86df-4829-b77e-4beabd9b059e.jsonl": 1779649674956,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/a84a4496-811b-4012-aca4-2244045cbbff.jsonl": 1779660980933,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/b0e765c1-5ee7-4beb-bd77-0f9b9a151923.jsonl": 1779660980513,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/c03896af-576a-43ab-a799-4f16bab35269.jsonl": 1779644786488,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/c37d717d-f2e2-4dd0-9ed9-839ceeb1cc4d.jsonl": 1779660981864,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/16214191-7091-4aef-a309-f922d351d79f/subagents/c86141a3-3209-4b9a-9766-07aea5dc9a69.jsonl": 1779658292250,
|
||||
"/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,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/394af47d-c5cd-4cef-bef2-2192717439f8/subagents/0927d889-66b3-4007-87b4-15f8ad9e01f0.jsonl": 1778951401282,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/394af47d-c5cd-4cef-bef2-2192717439f8/subagents/0ddd911c-403c-4d90-a189-069679758338.jsonl": 1778951533153,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/394af47d-c5cd-4cef-bef2-2192717439f8/subagents/59f0c95a-415f-440a-bae2-96020aca9033.jsonl": 1778951400523,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/394af47d-c5cd-4cef-bef2-2192717439f8/subagents/dc63a53e-55bc-4175-b49e-637b408138ac.jsonl": 1778951399831,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/394af47d-c5cd-4cef-bef2-2192717439f8/subagents/f0ad176d-04d7-4d9a-82b8-65273acd313a.jsonl": 1778946728971,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/401dfc2b-5e6d-4479-8702-7b544e6de7de/401dfc2b-5e6d-4479-8702-7b544e6de7de.jsonl": 1781970053022,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/5039e847-3035-4f43-b184-46aeceb06764/5039e847-3035-4f43-b184-46aeceb06764.jsonl": 1778838518325,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/5039e847-3035-4f43-b184-46aeceb06764/subagents/e13034a9-05cf-47e3-afa0-f6b142866ab1.jsonl": 1778837589740,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/5923e37e-370d-4867-95d0-751622982859/5923e37e-370d-4867-95d0-751622982859.jsonl": 1778968000388,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/5ac57758-0a3c-4502-9473-b63413a39013/5ac57758-0a3c-4502-9473-b63413a39013.jsonl": 1778921288478,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/5ac57758-0a3c-4502-9473-b63413a39013/subagents/b2833767-42d4-4d3f-952e-b961ea5538d3.jsonl": 1778917054076,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/65570f8a-5cd2-4573-b2d9-0983f2922d1f/65570f8a-5cd2-4573-b2d9-0983f2922d1f.jsonl": 1778231172346,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/65570f8a-5cd2-4573-b2d9-0983f2922d1f/subagents/b9a447c6-5a63-4882-b878-5aee9756ce25.jsonl": 1778227602626,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/65570f8a-5cd2-4573-b2d9-0983f2922d1f/subagents/e2881041-49a0-4dca-8df1-614a7a070038.jsonl": 1778226771429,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/7b6c0ed0-caad-4157-b048-535452685b73/7b6c0ed0-caad-4157-b048-535452685b73.jsonl": 1778852401511,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/8c2fc9f5-c359-4c67-a0f5-325ee44cebc9/8c2fc9f5-c359-4c67-a0f5-325ee44cebc9.jsonl": 1778751052502,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/90c791ad-a274-4673-b5f6-ec1bccaccc98/90c791ad-a274-4673-b5f6-ec1bccaccc98.jsonl": 1779566465299,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/90c791ad-a274-4673-b5f6-ec1bccaccc98/subagents/1f1b2143-916d-4398-a8e0-4bfb993df3d7.jsonl": 1779547337406,
|
||||
"/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/92d73875-5939-48fb-9f68-86c88b0f2ff7/subagents/401ab052-4346-4e0d-8ca9-108c0a5b1a61.jsonl": 1778964224375,
|
||||
"/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/b85430f3-4520-47fd-9b4b-5200ca340a36/subagents/f973ca95-be8f-4c00-a00d-4f026e5bd4dc.jsonl": 1779026546575,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ca85061e-6af9-4250-8dc7-9c3bb4839c48/ca85061e-6af9-4250-8dc7-9c3bb4839c48.jsonl": 1778849848444,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ca85061e-6af9-4250-8dc7-9c3bb4839c48/subagents/3bbaec3b-7dce-4eee-916e-7673710c1e13.jsonl": 1778848753214,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/d92dfb04-c148-4a14-a48a-39d4c634caee/d92dfb04-c148-4a14-a48a-39d4c634caee.jsonl": 1778861502433,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/e3745f62-c3b9-4a21-8942-71bc6f603f77/e3745f62-c3b9-4a21-8942-71bc6f603f77.jsonl": 1778018654221,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/fb7fd15f-b9ef-490b-a1de-8238ea026e53/fb7fd15f-b9ef-490b-a1de-8238ea026e53.jsonl": 1779998515529
|
||||
}
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/e3745f62-c3b9-4a21-8942-71bc6f603f77/subagents/f028b51a-8a84-4a45-8866-95cb05ca9727.jsonl": 1778014992372,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ea18d177-228d-4e44-8e79-8957e6f2da39/ea18d177-228d-4e44-8e79-8957e6f2da39.jsonl": 1781975528735,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ea18d177-228d-4e44-8e79-8957e6f2da39/subagents/375b2e07-202f-4e8e-9e80-718bbdf88005.jsonl": 1781973640324,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ea18d177-228d-4e44-8e79-8957e6f2da39/subagents/6cba0291-51b3-42d9-939c-2e95d01128f8.jsonl": 1781975383642,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ea18d177-228d-4e44-8e79-8957e6f2da39/subagents/d6546245-4e3f-47dd-bc3e-39e1af726138.jsonl": 1781973598054,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/ec2b194c-c67e-4089-a434-6daff69ca69d.jsonl": 1782057901186,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/99e20a50-1e7b-4ed8-931a-5c4c7a65ea4d.jsonl": 1782037965469,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/f0ad176d-04d7-4d9a-82b8-65273acd313a/subagents/96507ccc-6150-4260-a55c-94abd2b57441.jsonl": 1778946698447,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/fb7fd15f-b9ef-490b-a1de-8238ea026e53/fb7fd15f-b9ef-490b-a1de-8238ea026e53.jsonl": 1780001507987,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/fb7fd15f-b9ef-490b-a1de-8238ea026e53/subagents/a5601ff1-7934-4872-acd8-266e416c3680.jsonl": 1779998536313,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/993325c9-a748-4183-9b2d-866cc0d73338.jsonl": 1782056882673,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/e483aeb3-3b38-4f7f-9ad8-d79f362c102d.jsonl": 1782057932702
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"lastRunAtMs": 1781973755639,
|
||||
"turnsSinceLastRun": 4,
|
||||
"lastTranscriptMtimeMs": 1781973755517.7488,
|
||||
"lastProcessedGenerationId": "bcd357e0-6c9c-4e2b-b7dd-ee6d7c50d52a",
|
||||
"lastRunAtMs": 1782057894900,
|
||||
"turnsSinceLastRun": 15,
|
||||
"lastTranscriptMtimeMs": 1782057894796.2778,
|
||||
"lastProcessedGenerationId": "3c5517c8-a954-41c8-ba2d-9a8578c05e67",
|
||||
"trialStartedAtMs": null
|
||||
}
|
||||
|
||||
18
AGENTS.md
18
AGENTS.md
@@ -1,31 +1,31 @@
|
||||
# Agent memory (Momento)
|
||||
# Agent memory (Memento)
|
||||
|
||||
## Learned User Preferences
|
||||
|
||||
- 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.**
|
||||
- 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/Memento/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 ; **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. **CI/CD Gitea spécifique** : (1) `actions/upload-artifact` et `download-artifact` doivent utiliser **@v3** (pas @v4 — non supporté par Gitea, erreur `GHESNotSupportedError`) ; (2) `Dockerfile.socket.prebuilt` doit utiliser `--legacy-peer-deps` dans `npm install` (conflit TipTap 3.22.5 vs 3.23.6) ; (3) le serveur de prod (192.168.1.190) **ne peut pas pull Docker Hub** (DNS cassé) — le build Docker complet échoue, seul le chemin prebuilt artifact fonctionne ; (4) `docker-entrypoint.sh` applique les migrations Prisma **avant** de démarrer le serveur Next.js (ordre correct) ; (5) rollback d'urgence : `docker tag memento-memento-note:rollback memento-memento-note:latest && docker compose up -d --force-recreate memento-note` ; **TRAVAILLER SUR UNE BRANCHE** pendant le dev, ne push sur `main` que quand le code est testé et fonctionnel — chaque push sur `main` déclenche un déploiement automatique en production.
|
||||
- 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 ; **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` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **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) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; 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.
|
||||
- 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` — éditeur courant à **gauche**, note liée en lecture seule à **droite** en LTR ; **inversé en RTL** `fa`/`ar`), **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) ; **ne jamais remettre du code que l'utilisateur a explicitement retiré sans demander d'abord** (ex. `reserveUsageOrThrow` retiré intentionnellement de `organize-notebook.ts` — agent l'a remis sans demander → user mécontent) ; **correction i18n ou spec doc** : **ne pas refondre logique/UI** hors scope (ex. US-4 `structuredViewBlock` — garder le dual-mode base locale + lien carnet, pas de suppression du mode local) ; 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` ; briefs pour outil de design externe sur demande.
|
||||
- **Facturation & quotas IA** : limites mensuelles, tiers (BASIC/PRO/BUSINESS/ENTERPRISE) et Price IDs Stripe via **Admin > Facturation & quotas** (`/admin/billing`) — pas via `.env` pour le métier ; secrets Stripe (`STRIPE_SECRET_KEY`, webhook) restent en env serveur ; doc `memento-note/docs/admin-billing-quotas-guide.md`.
|
||||
|
||||
## Learned Workspace Facts
|
||||
|
||||
- Application Next.js principalement sous `memento-note/`.
|
||||
- Référentiels design du workspace : `architectural-grid1/` et `architectural-grid/` à la racine du repo Momento.
|
||||
- Référentiels design du workspace : `architectural-grid1/` et `architectural-grid/` à la racine du repo Memento.
|
||||
- i18n : 15 fichiers sous `memento-note/locales/` (de, en, es, fr, it, pt, nl, pl, ru, zh, ja, ko, ar, fa, hi) ; logique sous `memento-note/lib/i18n/` ; référence `en.json` (~2218 clés) ; auditer les « non traduits » par flatten EN vs locale (souvent valeurs identiques à l'EN).
|
||||
- Workflow BMad : stories sous `docs/` (ex. `3-4-host-pays-session-logic.md`), suivi sprint dans `docs/sprint-status.yaml` et stories courantes dans `docs/user-stories.md` ; skills sous `.claude/skills/bmad-*` ; `_bmad-output/planning-artifacts` souvent vide — planification de référence dans `docs/` ; préférer **une user story par feature** (pas de stories groupées).
|
||||
- PostgreSQL Docker (`memento-postgres`) sur le port 5433 ; Redis Docker (`memento-redis`) sur le port 6379 (voir règles projet).
|
||||
- Règles opérationnelles Prisma et sécurité base de données décrites dans `CLAUDE.md` à la racine du repo.
|
||||
- PostgreSQL Docker (`memento-postgres`) port 5433 ; Redis (`memento-redis`) port 6379 ; règles Prisma/DB dans `CLAUDE.md`.
|
||||
- **Admin facturation** : page `/admin/billing` (`billing-admin-client.tsx`, actions `admin-billing.ts`) — quotas par feature IA et config Stripe métier en base, effet ~60 s ; guide `memento-note/docs/admin-billing-quotas-guide.md`.
|
||||
- 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 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` ; **US-4 `structuredViewBlock`** (`tiptap-structured-view-block-extension.tsx`, `structured-view-block-embed.tsx`) : **dual-mode** — base locale autonome par défaut (`/database`, `/vue`, `isLocal: true`) + option « Lier à un carnet » (Structured Views) ; i18n `structuredViewBlock.*` ; **rejeté** : ancien `databaseBlock` « Auteurs & Œuvres » et spec embed-only `docs/story-nextgen-editor-us4-redesign.md` ; 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` ; **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.
|
||||
- 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 ; **Wizards** : `NotebookOrganizerDialog` (tags/doublons) branché via bouton "Tags IA" dans `home-client.tsx` — `StructuredViewsWizard` encore **orphelin** (pas de point d'entrée UI) ; **Publication web** (`note-editor-toolbar.tsx`) : deux modes — simple (copie directe) + IA (2-3 templates, reformulation adaptée au contenu, KaTeX pour équations, images incluses) — quota IA consommé uniquement sur publication IA.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Momento Project Rules
|
||||
# Memento Project Rules
|
||||
|
||||
## CRITICAL — DATABASE SAFETY
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ chown memento-deploy:memento-deploy /opt/memento
|
||||
```bash
|
||||
su - memento-deploy
|
||||
cd /opt/memento
|
||||
git clone https://gitea.parsanet.org/sepehr/Momento.git .
|
||||
git clone https://gitea.parsanet.org/sepehr/Memento.git .
|
||||
git config credential.helper store
|
||||
# Le premier pull demandera les identifiants Gitea
|
||||
```
|
||||
@@ -110,7 +110,7 @@ Redemarrer Gitea.
|
||||
|
||||
### 2. Recuperer le token d'enregistrement
|
||||
|
||||
Aller sur : **gitea.parsanet.org > Momento > Settings > Actions > Runners > "New Runner"**
|
||||
Aller sur : **gitea.parsanet.org > Memento > Settings > Actions > Runners > "New Runner"**
|
||||
Copier le token affiche.
|
||||
|
||||
### 3. Installer act_runner sur 192.168.1.190
|
||||
@@ -179,7 +179,7 @@ systemctl status gitea-runner
|
||||
systemctl status gitea-runner
|
||||
# Doit etre "active (running)"
|
||||
|
||||
# Sur gitea.parsanet.org > Momento > Settings > Actions > Runners
|
||||
# Sur gitea.parsanet.org > Memento > Settings > Actions > Runners
|
||||
# Le runner "memento-deploy" doit apparaitre avec le label "docker-host"
|
||||
```
|
||||
|
||||
@@ -213,7 +213,7 @@ git add README.md
|
||||
git commit -m "test: CI/CD deploy"
|
||||
git push origin main
|
||||
|
||||
# Verifier sur gitea.parsanet.org > Momento > Actions
|
||||
# Verifier sur gitea.parsanet.org > Memento > Actions
|
||||
# Le job doit apparaitre et se terminer en succes
|
||||
```
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Serveur Docker (192.168.1.190)
|
||||
|
||||
## Variables (non-sensibles)
|
||||
|
||||
Aller sur : **`Momento → Settings → Actions → Variables`**
|
||||
Aller sur : **`Memento → Settings → Actions → Variables`**
|
||||
|
||||
| Nom | Exemple | Description |
|
||||
|-----|---------|-------------|
|
||||
@@ -59,7 +59,7 @@ Aller sur : **`Momento → Settings → Actions → Variables`**
|
||||
|
||||
## Secrets (sensibles)
|
||||
|
||||
Aller sur : **`Momento → Settings → Actions → Secrets`**
|
||||
Aller sur : **`Memento → Settings → Actions → Secrets`**
|
||||
|
||||
| Nom | Description |
|
||||
|-----|-------------|
|
||||
@@ -138,11 +138,11 @@ Ou dans l'interface admin : **Admin → Settings → Configuration Email → Res
|
||||
Chaque `git push` sur la branche `main` déclenche automatiquement le déploiement.
|
||||
|
||||
### Manuel (depuis Gitea)
|
||||
`Momento → Actions → "Deploy to Production" → Run workflow → Run workflow`
|
||||
`Memento → Actions → "Deploy to Production" → Run workflow → Run workflow`
|
||||
|
||||
### Manuel (depuis le terminal)
|
||||
```bash
|
||||
cd D:/dev1405/Momento
|
||||
cd D:/dev1405/Memento
|
||||
git commit --allow-empty -m "ci: trigger deploy"
|
||||
git push origin main
|
||||
```
|
||||
@@ -167,7 +167,7 @@ git push origin main
|
||||
# Sur 192.168.1.190 :
|
||||
mkdir -p /opt/memento
|
||||
cd /opt/memento
|
||||
git clone https://gitea.parsanet.org/sepehr/Momento.git .
|
||||
git clone https://gitea.parsanet.org/sepehr/Memento.git .
|
||||
|
||||
# Générer les secrets si pas encore configurés dans Gitea :
|
||||
# openssl rand -base64 32 → NEXTAUTH_SECRET
|
||||
|
||||
18
GUIDE.en.md
18
GUIDE.en.md
@@ -79,7 +79,7 @@
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Momento/
|
||||
Memento/
|
||||
├── docker-compose.yml # Multi-container orchestration
|
||||
├── .env.docker.example # Docker config template
|
||||
├── GUIDE.md # This guide (FR)
|
||||
@@ -147,8 +147,8 @@ Browser -> Next.js App Router
|
||||
### Quick Setup (interactive script)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/votre-user/Momento.git
|
||||
cd Momento
|
||||
git clone https://github.com/votre-user/Memento.git
|
||||
cd Memento
|
||||
|
||||
# macOS / Linux
|
||||
./scripts/deploy-local.sh
|
||||
@@ -167,8 +167,8 @@ The script will:
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/votre-user/Momento.git
|
||||
cd Momento/memento-note
|
||||
git clone https://github.com/votre-user/Memento.git
|
||||
cd Memento/memento-note
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install --legacy-peer-deps
|
||||
@@ -200,8 +200,8 @@ npm run dev
|
||||
### Quick Setup (interactive script)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/votre-user/Momento.git
|
||||
cd Momento
|
||||
git clone https://github.com/votre-user/Memento.git
|
||||
cd Memento
|
||||
|
||||
# macOS / Linux
|
||||
./scripts/deploy-docker.sh
|
||||
@@ -231,8 +231,8 @@ The script will:
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/votre-user/Momento.git
|
||||
cd Momento
|
||||
git clone https://github.com/votre-user/Memento.git
|
||||
cd Memento
|
||||
|
||||
# 2. Configure the environment
|
||||
cp .env.docker.example .env.docker
|
||||
|
||||
18
GUIDE.md
18
GUIDE.md
@@ -80,7 +80,7 @@
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Momento/
|
||||
Memento/
|
||||
├── docker-compose.yml # Orchestration multi-conteneurs
|
||||
├── .env.docker.example # Template config Docker
|
||||
├── GUIDE.md # Ce guide (FR)
|
||||
@@ -148,8 +148,8 @@ Navigateur -> Next.js App Router
|
||||
### Installation rapide (script interactif)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/votre-user/Momento.git
|
||||
cd Momento
|
||||
git clone https://github.com/votre-user/Memento.git
|
||||
cd Memento
|
||||
|
||||
# macOS / Linux
|
||||
./scripts/deploy-local.sh
|
||||
@@ -168,8 +168,8 @@ Le script va :
|
||||
|
||||
```bash
|
||||
# 1. Cloner le depot
|
||||
git clone https://github.com/votre-user/Momento.git
|
||||
cd Momento/memento-note
|
||||
git clone https://github.com/votre-user/Memento.git
|
||||
cd Memento/memento-note
|
||||
|
||||
# 2. Installer les dependances
|
||||
npm install --legacy-peer-deps
|
||||
@@ -201,8 +201,8 @@ npm run dev
|
||||
### Installation rapide (script interactif)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/votre-user/Momento.git
|
||||
cd Momento
|
||||
git clone https://github.com/votre-user/Memento.git
|
||||
cd Memento
|
||||
|
||||
# macOS / Linux
|
||||
./scripts/deploy-docker.sh
|
||||
@@ -232,8 +232,8 @@ Le script va :
|
||||
|
||||
```bash
|
||||
# 1. Cloner le depot
|
||||
git clone https://github.com/votre-user/Momento.git
|
||||
cd Momento
|
||||
git clone https://github.com/votre-user/Memento.git
|
||||
cd Memento
|
||||
|
||||
# 2. Configurer l'environnement
|
||||
cp .env.docker.example .env.docker
|
||||
|
||||
16
README.fr.md
16
README.fr.md
@@ -50,8 +50,8 @@ Une application de prise de notes intelligente et powered by IA. Comme Google Ke
|
||||
Le script de deploiement interactif s'occupe de tout - configuration, build et demarrage :
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/Momento.git
|
||||
cd Momento
|
||||
git clone https://github.com/yourusername/Memento.git
|
||||
cd Memento
|
||||
|
||||
# macOS / Linux
|
||||
./scripts/deploy-docker.sh
|
||||
@@ -73,8 +73,8 @@ docker compose up -d
|
||||
### Developpement local
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/Momento.git
|
||||
cd Momento
|
||||
git clone https://github.com/yourusername/Memento.git
|
||||
cd Memento
|
||||
|
||||
# macOS / Linux
|
||||
./scripts/deploy-local.sh
|
||||
@@ -86,7 +86,7 @@ cd Momento
|
||||
Ou manuellement :
|
||||
|
||||
```bash
|
||||
cd Momento/memento-note
|
||||
cd Memento/memento-note
|
||||
cp .env.example .env
|
||||
# Editer .env avec DATABASE_URL, NEXTAUTH_SECRET, ADMIN_EMAIL, etc.
|
||||
npm install --legacy-peer-deps
|
||||
@@ -170,7 +170,7 @@ Pour le guide complet d'installation, deploiement et configuration, voir **[GUID
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
Momento/
|
||||
Memento/
|
||||
├── docker-compose.yml # Orchestration multi-conteneurs
|
||||
├── .env.docker.example # Template environnement Docker
|
||||
├── scripts/ # Scripts de deploiement
|
||||
@@ -215,8 +215,8 @@ Voir [.env.docker.example](.env.docker.example) pour la liste complete. Variable
|
||||
|
||||
Les contributions sont bienvenues !
|
||||
|
||||
- **Rapports de bugs** : [Ouvrir une issue](https://github.com/yourusername/Momento/issues)
|
||||
- **Idees de fonctionnalites** : [Lancer une discussion](https://github.com/yourusername/Momento/discussions)
|
||||
- **Rapports de bugs** : [Ouvrir une issue](https://github.com/yourusername/Memento/issues)
|
||||
- **Idees de fonctionnalites** : [Lancer une discussion](https://github.com/yourusername/Memento/discussions)
|
||||
- **Pull requests** : Fork, creer une branche, et soumettre une PR
|
||||
|
||||
---
|
||||
|
||||
16
README.md
16
README.md
@@ -50,8 +50,8 @@ A smart, AI-powered note-taking app. Like Google Keep, but with notebooks, seman
|
||||
The interactive deploy script handles everything - environment config, container build, and startup:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/Momento.git
|
||||
cd Momento
|
||||
git clone https://github.com/yourusername/Memento.git
|
||||
cd Memento
|
||||
|
||||
# macOS / Linux
|
||||
./scripts/deploy-docker.sh
|
||||
@@ -73,8 +73,8 @@ docker compose up -d
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/Momento.git
|
||||
cd Momento
|
||||
git clone https://github.com/yourusername/Memento.git
|
||||
cd Memento
|
||||
|
||||
# macOS / Linux
|
||||
./scripts/deploy-local.sh
|
||||
@@ -86,7 +86,7 @@ cd Momento
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
cd Momento/memento-note
|
||||
cd Memento/memento-note
|
||||
cp .env.example .env
|
||||
# Edit .env with your DATABASE_URL, NEXTAUTH_SECRET, ADMIN_EMAIL, etc.
|
||||
npm install --legacy-peer-deps
|
||||
@@ -170,7 +170,7 @@ For the complete installation, deployment, and configuration guide, see **[GUIDE
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Momento/
|
||||
Memento/
|
||||
├── docker-compose.yml # Multi-container orchestration
|
||||
├── .env.docker.example # Docker environment template
|
||||
├── scripts/ # Deployment scripts
|
||||
@@ -215,8 +215,8 @@ See [.env.docker.example](.env.docker.example) for the complete list. Key variab
|
||||
|
||||
Contributions are welcome!
|
||||
|
||||
- **Bug reports**: [Open an issue](https://github.com/yourusername/Momento/issues)
|
||||
- **Feature ideas**: [Start a discussion](https://github.com/yourusername/Momento/discussions)
|
||||
- **Bug reports**: [Open an issue](https://github.com/yourusername/Memento/issues)
|
||||
- **Feature ideas**: [Start a discussion](https://github.com/yourusername/Memento/discussions)
|
||||
- **Pull requests**: Fork, create a branch, and submit a PR
|
||||
|
||||
---
|
||||
|
||||
@@ -14,7 +14,7 @@ context:
|
||||
|
||||
## Intent
|
||||
|
||||
**Problem:** The Momento workspace spans multiple deployable and reference projects (memento-note, mcp-server, extension, mobile, monitoring, CI/CD, prototypes) without a consolidated security and quality audit; several P0 issues were found (open uploads, MCP auth bypass, fail-open quotas).
|
||||
**Problem:** The Memento workspace spans multiple deployable and reference projects (memento-note, mcp-server, extension, mobile, monitoring, CI/CD, prototypes) without a consolidated security and quality audit; several P0 issues were found (open uploads, MCP auth bypass, fail-open quotas).
|
||||
|
||||
**Approach:** Deliver a prioritized cross-project audit report (bugs + improvements) and, upon approval, remediate Phase 1 P0 security items first — app uploads/auth, MCP tenant isolation, extension Store readiness — before UX/i18n/prototype parity work.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ completedDate: 2026-05-24
|
||||
|
||||
## Context
|
||||
|
||||
Momento currently uses MCP SDK v1.0.4 with a working but potentially fragile implementation. With MCP SDK v2 coming in Q1 2026, we need to:
|
||||
Memento currently uses MCP SDK v1.0.4 with a working but potentially fragile implementation. With MCP SDK v2 coming in Q1 2026, we need to:
|
||||
1. Make the current implementation more robust
|
||||
2. Prepare for v2 migration
|
||||
3. Add production-ready features
|
||||
|
||||
@@ -98,7 +98,7 @@ note: >
|
||||
|
||||
## Design Notes
|
||||
|
||||
**Why reference-only attrs?** Storing note rows in TipTap HTML scales to O(notes × properties) per keystroke — unacceptable for perf. The reference pattern (`notebookId` only) is how Notion "linked database" blocks work and aligns with Momento's already-existing API layer.
|
||||
**Why reference-only attrs?** Storing note rows in TipTap HTML scales to O(notes × properties) per keystroke — unacceptable for perf. The reference pattern (`notebookId` only) is how Notion "linked database" blocks work and aligns with Memento's already-existing API layer.
|
||||
|
||||
**notebookId propagation:** The editor currently receives only `noteId`. The smallest change is to add `notebookId` as a prop to `RichTextEditor` (already used by `note-content-area.tsx`). No context change needed.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ visual_tools: intermediate
|
||||
|
||||
# Core Configuration Values
|
||||
user_name: Devparsa
|
||||
project_name: Momento
|
||||
project_name: Memento
|
||||
communication_language: French
|
||||
document_output_language: English
|
||||
output_folder: "{project-root}/_bmad-output"
|
||||
|
||||
@@ -14,7 +14,7 @@ design_experience: intermediate
|
||||
|
||||
# Core Configuration Values
|
||||
user_name: Devparsa
|
||||
project_name: Momento
|
||||
project_name: Memento
|
||||
communication_language: French
|
||||
document_output_language: English
|
||||
output_folder: "{project-root}/_bmad-output"
|
||||
|
||||
@@ -327,7 +327,7 @@ export const AgentsView: React.FC<AgentsViewProps> = ({
|
||||
'Connexion SSH sans mot de passe à devSandbox',
|
||||
'Gateway token (blank to generate)',
|
||||
'Procédure d\'accès à openclaw',
|
||||
'Derniers commits du repo Momento'
|
||||
'Derniers commits du repo Memento'
|
||||
].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
|
||||
|
||||
@@ -44,7 +44,7 @@ export const AuthPage: React.FC<AuthPageProps> = ({ onAuthComplete, onBack, init
|
||||
<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>
|
||||
<span className="font-serif text-xl font-medium tracking-tight text-ink">Memento</span>
|
||||
</div>
|
||||
|
||||
<div className="w-24" /> {/* Spacer */}
|
||||
@@ -191,7 +191,7 @@ export const AuthPage: React.FC<AuthPageProps> = ({ onAuthComplete, onBack, init
|
||||
</AnimatePresence>
|
||||
|
||||
<p className="text-center mt-8 text-[9px] text-concrete font-bold uppercase tracking-[0.3em] opacity-40">
|
||||
© 2024 Momento Labs — Privacy • Terms
|
||||
© 2024 Memento Labs — Privacy • Terms
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -124,7 +124,7 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
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.");
|
||||
throw new Error("Connexion réseau interrompue. L'extension n'a pas pu joindre les serveurs Memento.");
|
||||
}
|
||||
|
||||
const dateStr = new Date().toLocaleDateString('fr-FR', {
|
||||
@@ -175,10 +175,10 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
setLastCreatedNoteId(newNoteId);
|
||||
setClipperState('success');
|
||||
|
||||
// Add note to Momento Database
|
||||
// Add note to Memento Database
|
||||
onAddNote(newNote);
|
||||
|
||||
// Fire real-time notification toast in Momento!
|
||||
// Fire real-time notification toast in Memento!
|
||||
onTriggerToast(clipTitle, newNoteId);
|
||||
|
||||
} catch (err: any) {
|
||||
@@ -260,7 +260,7 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
{/* 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"
|
||||
title="Memento 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">
|
||||
@@ -356,12 +356,12 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
{/* 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 */}
|
||||
{/* Memento 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-xs font-bold font-serif text-ink dark:text-dark-ink tracking-tight">Memento</span>
|
||||
<span className="text-[10px] text-accent block font-mono font-medium tracking-widest uppercase">Web Clipper</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -561,7 +561,7 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
}}
|
||||
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
|
||||
Voir dans Memento
|
||||
<ArrowUpRight size={14} />
|
||||
</button>
|
||||
|
||||
@@ -608,7 +608,7 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
|
||||
{/* 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
|
||||
Memento Companion v2.1.2 • Sécurisé HTTPS TLS 1.3
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
<div className="w-10 h-10 bg-ink flex items-center justify-center rounded-xl shadow-lg rotate-3 group hover:rotate-0 transition-transform cursor-pointer">
|
||||
<span className="text-paper font-serif text-2xl font-bold">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Momento</span>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Memento</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-10">
|
||||
@@ -86,7 +86,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
<span className="italic">enfin amplifié.</span>
|
||||
</h1>
|
||||
<p className="max-w-2xl mx-auto text-lg md:text-xl text-concrete font-light leading-relaxed mb-12">
|
||||
Momento n'est pas qu'une simple application de notes. C'est un écosystème intelligent qui connecte, analyse et développe vos idées en temps réel grâce à 6 types d'agents IA et une recherche sémantique de pointe.
|
||||
Memento n'est pas qu'une simple application de notes. C'est un écosystème intelligent qui connecte, analyse et développe vos idées en temps réel grâce à 6 types d'agents IA et une recherche sémantique de pointe.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
@@ -155,7 +155,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink">Une intelligence fluide, <br />intégrée à chaque mot.</h2>
|
||||
</div>
|
||||
<div className="text-concrete font-light">
|
||||
Momento orchestres vos idées grâce à une architecture multi-fournisseurs.
|
||||
Memento orchestres vos idées grâce à une architecture multi-fournisseurs.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -352,7 +352,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
{
|
||||
name: "Basic",
|
||||
price: "Gratuit",
|
||||
desc: "Pour découvrir la magie de Momento.",
|
||||
desc: "Pour découvrir la magie de Memento.",
|
||||
features: ["100 Notes max", "3 Carnets", "50 crédits IA (Lifetime)", "Recherche sémantique", "Historique 7 jours"],
|
||||
cta: "Commencer",
|
||||
popular: false
|
||||
@@ -433,7 +433,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
</div>
|
||||
<h3 className="text-3xl font-serif font-medium mb-4">La stratégie BYOK</h3>
|
||||
<p className="text-concrete font-light leading-relaxed mb-6">
|
||||
Vous possédez déjà des clés API OpenAI, Anthropic ou Google ? Connectez-les directement à Momento.
|
||||
Vous possédez déjà des clés API OpenAI, Anthropic ou Google ? Connectez-les directement à Memento.
|
||||
Utilisez l'IA sans limites de crédits imposées, en payant uniquement ce que vous consommez chez votre fournisseur favori.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -472,12 +472,12 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
<section className="py-40 px-8 text-center bg-paper relative overflow-hidden">
|
||||
<div className="max-w-3xl mx-auto relative z-10">
|
||||
<h2 className="text-5xl md:text-7xl font-serif tracking-tight mb-8 leading-tight">Prêt à libérer votre <br /><span className="italic">plein potentiel ?</span></h2>
|
||||
<p className="text-lg text-concrete font-light mb-12">Rejoignez des milliers de chercheurs, designers et penseurs qui utilisent déjà Momento pour construire leur futur.</p>
|
||||
<p className="text-lg text-concrete font-light mb-12">Rejoignez des milliers de chercheurs, designers et penseurs qui utilisent déjà Memento pour construire leur futur.</p>
|
||||
<button
|
||||
onClick={onEnter}
|
||||
className="px-16 py-6 bg-ink text-paper rounded-[32px] text-lg font-bold uppercase tracking-[0.2em] hover:scale-105 transition-all shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)]"
|
||||
>
|
||||
Lancer Momento
|
||||
Lancer Memento
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -489,7 +489,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
<div className="flex-1 space-y-8">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-accent/10 text-accent text-[9px] font-bold uppercase tracking-widest">
|
||||
<Globe size={12} />
|
||||
Écosystème Momento
|
||||
Écosystème Memento
|
||||
</div>
|
||||
<h3 className="text-4xl font-serif font-medium leading-tight text-ink">
|
||||
Traduisez vos documents.<br />
|
||||
@@ -558,7 +558,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
<div className="w-8 h-8 bg-ink flex items-center justify-center rounded-lg">
|
||||
<span className="text-paper font-serif text-lg font-bold">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl medium tracking-tight">Momento</span>
|
||||
<span className="font-serif text-xl medium tracking-tight">Memento</span>
|
||||
</div>
|
||||
<p className="text-sm text-concrete font-light max-w-xs">Le second cerveau amplifié par l'IA. Pensé pour les esprits créatifs.</p>
|
||||
</div>
|
||||
|
||||
@@ -602,7 +602,7 @@ export const SearchModal: React.FC<SearchModalProps> = ({
|
||||
|
||||
<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>
|
||||
<span>Memento Search OS v2.3</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -9,7 +9,7 @@ export const BillingTab: React.FC = () => {
|
||||
name: 'Plan Basic',
|
||||
price: 'Gratuit',
|
||||
period: '',
|
||||
description: 'Pour découvrir la magie de Momento.',
|
||||
description: 'Pour découvrir la magie de Memento.',
|
||||
features: [
|
||||
'100 Notes max',
|
||||
'3 Carnets',
|
||||
@@ -119,7 +119,7 @@ export const BillingTab: React.FC = () => {
|
||||
</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>
|
||||
<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 Memento.</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>
|
||||
|
||||
@@ -327,7 +327,7 @@ export const AgentsView: React.FC<AgentsViewProps> = ({
|
||||
'Connexion SSH sans mot de passe à devSandbox',
|
||||
'Gateway token (blank to generate)',
|
||||
'Procédure d\'accès à openclaw',
|
||||
'Derniers commits du repo Momento'
|
||||
'Derniers commits du repo Memento'
|
||||
].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
|
||||
|
||||
@@ -44,7 +44,7 @@ export const AuthPage: React.FC<AuthPageProps> = ({ onAuthComplete, onBack, init
|
||||
<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>
|
||||
<span className="font-serif text-xl font-medium tracking-tight text-ink">Memento</span>
|
||||
</div>
|
||||
|
||||
<div className="w-24" /> {/* Spacer */}
|
||||
@@ -191,7 +191,7 @@ export const AuthPage: React.FC<AuthPageProps> = ({ onAuthComplete, onBack, init
|
||||
</AnimatePresence>
|
||||
|
||||
<p className="text-center mt-8 text-[9px] text-concrete font-bold uppercase tracking-[0.3em] opacity-40">
|
||||
© 2024 Momento Labs — Privacy • Terms
|
||||
© 2024 Memento Labs — Privacy • Terms
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -124,7 +124,7 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
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.");
|
||||
throw new Error("Connexion réseau interrompue. L'extension n'a pas pu joindre les serveurs Memento.");
|
||||
}
|
||||
|
||||
const dateStr = new Date().toLocaleDateString('fr-FR', {
|
||||
@@ -175,10 +175,10 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
setLastCreatedNoteId(newNoteId);
|
||||
setClipperState('success');
|
||||
|
||||
// Add note to Momento Database
|
||||
// Add note to Memento Database
|
||||
onAddNote(newNote);
|
||||
|
||||
// Fire real-time notification toast in Momento!
|
||||
// Fire real-time notification toast in Memento!
|
||||
onTriggerToast(clipTitle, newNoteId);
|
||||
|
||||
} catch (err: any) {
|
||||
@@ -260,7 +260,7 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
{/* 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"
|
||||
title="Memento 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">
|
||||
@@ -356,12 +356,12 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
{/* 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 */}
|
||||
{/* Memento 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-xs font-bold font-serif text-ink dark:text-dark-ink tracking-tight">Memento</span>
|
||||
<span className="text-[10px] text-accent block font-mono font-medium tracking-widest uppercase">Web Clipper</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -561,7 +561,7 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
}}
|
||||
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
|
||||
Voir dans Memento
|
||||
<ArrowUpRight size={14} />
|
||||
</button>
|
||||
|
||||
@@ -608,7 +608,7 @@ export const ClipperSimulator: React.FC<ClipperSimulatorProps> = ({
|
||||
|
||||
{/* 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
|
||||
Memento Companion v2.1.2 • Sécurisé HTTPS TLS 1.3
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,7 +197,7 @@ const FR: LangDict = {
|
||||
hero_badge: "Augmenté par l'Intelligence Artificielle",
|
||||
hero_title_1: "Votre second cerveau,",
|
||||
hero_title_italic: "enfin amplifié.",
|
||||
hero_desc: "Momento n'est pas qu'une simple application de notes. C'est un écosystème intelligent qui connecte, analyse et développe vos idées en temps réel grâce à 6 types d'agents IA et une recherche sémantique de pointe.",
|
||||
hero_desc: "Memento n'est pas qu'une simple application de notes. C'est un écosystème intelligent qui connecte, analyse et développe vos idées en temps réel grâce à 6 types d'agents IA et une recherche sémantique de pointe.",
|
||||
hero_cta_start: "S'inscrire maintenant",
|
||||
hero_cta_features: "Voir les fonctionnalités",
|
||||
|
||||
@@ -210,7 +210,7 @@ const FR: LangDict = {
|
||||
features_badge: "Capacités IA",
|
||||
features_title_1: "Une intelligence fluide,",
|
||||
features_title_2: "intégrée à chaque mot.",
|
||||
features_subtitle: "Momento orchestre vos idées grâce à une architecture multi-fournisseurs.",
|
||||
features_subtitle: "Memento orchestre vos idées grâce à une architecture multi-fournisseurs.",
|
||||
|
||||
f1_title: "Recherche Sémantique",
|
||||
f1_desc: "Ne cherchez plus par mots-clés. Trouvez par concept. Notre moteur hybride Vector + FTS comprend l'intention derrière vos notes.",
|
||||
@@ -261,7 +261,7 @@ const FR: LangDict = {
|
||||
price_popular: "Le plus populaire",
|
||||
price_free_name: "Basic",
|
||||
price_free_price: "Gratuit",
|
||||
price_free_desc: "Pour découvrir la magie de Momento.",
|
||||
price_free_desc: "Pour découvrir la magie de Memento.",
|
||||
price_pro_name: "Pro",
|
||||
price_pro_price: "9,90€",
|
||||
price_pro_desc: "Pour les consultants et créateurs exigeants.",
|
||||
@@ -285,7 +285,7 @@ const FR: LangDict = {
|
||||
|
||||
byok_badge: "Technologie Cloud Ouverte",
|
||||
byok_title: "La stratégie BYOK",
|
||||
byok_desc: "Vous possédez déjà des clés API OpenAI, Anthropic ou Google ? Connectez-les directement à Momento. Utilisez l'IA sans limites de crédits imposées, en payant uniquement ce que vous consommez chez votre fournisseur favori.",
|
||||
byok_desc: "Vous possédez déjà des clés API OpenAI, Anthropic ou Google ? Connectez-les directement à Memento. Utilisez l'IA sans limites de crédits imposées, en payant uniquement ce que vous consommez chez votre fournisseur favori.",
|
||||
byok_col1_title: "Pas de lock-in",
|
||||
byok_col1_desc: "Changez de fournisseur en 1 clic.",
|
||||
byok_col2_title: "Coûts optimisés",
|
||||
@@ -294,10 +294,10 @@ const FR: LangDict = {
|
||||
|
||||
final_cta_title: "Prêt à libérer votre",
|
||||
final_cta_title_italic: "plein potentiel ?",
|
||||
final_cta_desc: "Rejoignez des milliers de chercheurs, designers et penseurs qui utilisent déjà Momento pour construire leur futur.",
|
||||
final_cta_button: "Lancer Momento",
|
||||
final_cta_desc: "Rejoignez des milliers de chercheurs, designers et penseurs qui utilisent déjà Memento pour construire leur futur.",
|
||||
final_cta_button: "Lancer Memento",
|
||||
|
||||
eco_badge: "Écosystème Momento",
|
||||
eco_badge: "Écosystème Memento",
|
||||
eco_title_1: "Traduisez vos documents.",
|
||||
eco_title_2: "Formatage préservé.",
|
||||
eco_desc: "Le seul traducteur qui préserve les graphiques, tables des matières, formes et en-têtes — exactement tels qu'ils étaient. Prolongez l'intelligence de vos notes à l'international.",
|
||||
@@ -352,7 +352,7 @@ const EN: LangDict = {
|
||||
hero_badge: "Amplified by Artificial Intelligence",
|
||||
hero_title_1: "Your second brain,",
|
||||
hero_title_italic: "finally amplified.",
|
||||
hero_desc: "Momento is not just a typical note-taking tool. It is an intelligent ecosystem that connects, analyzes, and scales your thoughts in real time with 6 autonomous AI agents and vector semantic search.",
|
||||
hero_desc: "Memento is not just a typical note-taking tool. It is an intelligent ecosystem that connects, analyzes, and scales your thoughts in real time with 6 autonomous AI agents and vector semantic search.",
|
||||
hero_cta_start: "Sign Up Now",
|
||||
hero_cta_features: "View Features",
|
||||
|
||||
@@ -365,7 +365,7 @@ const EN: LangDict = {
|
||||
features_badge: "AI Capabilities",
|
||||
features_title_1: "Fluid intelligence,",
|
||||
features_title_2: "integrated into every word.",
|
||||
features_subtitle: "Momento orchestrates your thoughts through a multi-provider landscape.",
|
||||
features_subtitle: "Memento orchestrates your thoughts through a multi-provider landscape.",
|
||||
|
||||
f1_title: "Semantic Search",
|
||||
f1_desc: "Stop searching by keywords. Retrieve by concept. Our hybrid Vector + FTS engine understands the core semantic context behind your logs.",
|
||||
@@ -416,7 +416,7 @@ const EN: LangDict = {
|
||||
price_popular: "Most Popular",
|
||||
price_free_name: "Basic",
|
||||
price_free_price: "Free",
|
||||
price_free_desc: "Get started with the foundational magic of Momento.",
|
||||
price_free_desc: "Get started with the foundational magic of Memento.",
|
||||
price_pro_name: "Pro",
|
||||
price_pro_price: "$9.90",
|
||||
price_pro_desc: "For consultants, writers, and advanced researchers.",
|
||||
@@ -449,10 +449,10 @@ const EN: LangDict = {
|
||||
|
||||
final_cta_title: "Ready to expand your",
|
||||
final_cta_title_italic: "second organic brain?",
|
||||
final_cta_desc: "Join thousands of academics, product architects, and minimalist designers scaling their insights with Momento.",
|
||||
final_cta_button: "Launch Momento",
|
||||
final_cta_desc: "Join thousands of academics, product architects, and minimalist designers scaling their insights with Memento.",
|
||||
final_cta_button: "Launch Memento",
|
||||
|
||||
eco_badge: "Momento Ecosystem",
|
||||
eco_badge: "Memento Ecosystem",
|
||||
eco_title_1: "Local document translation.",
|
||||
eco_title_2: "Format intact.",
|
||||
eco_desc: "The only translator preserving graphs, nested hierarchies, vectors, alignments, and titles exactly as you drew them. Take your local notes globally.",
|
||||
@@ -507,7 +507,7 @@ const JA: LangDict = {
|
||||
hero_badge: "人工知能による拡張済システム",
|
||||
hero_title_1: "あなたの第二の脳を、",
|
||||
hero_title_italic: "ついに具現化する。",
|
||||
hero_desc: "Momento(モメント)は単なるメモ帳ではありません。6体の専門AIエージェント、ハイブリッドベクトル検索を搭載し、リアルタイムで知識の接続、整理、展開を実行する知能エコシステムです。",
|
||||
hero_desc: "Memento(モメント)は単なるメモ帳ではありません。6体の専門AIエージェント、ハイブリッドベクトル検索を搭載し、リアルタイムで知識の接続、整理、展開を実行する知能エコシステムです。",
|
||||
hero_cta_start: "無料で体験する",
|
||||
hero_cta_features: "機能一覧を見る",
|
||||
|
||||
@@ -520,7 +520,7 @@ const JA: LangDict = {
|
||||
features_badge: "先進AI性能",
|
||||
features_title_1: "記述に完全に融合する、",
|
||||
features_title_2: "インテリジェンス。",
|
||||
features_subtitle: "Momentoは、複数のLLMプロバイダを容易に構成可能な適応性を備えています。",
|
||||
features_subtitle: "Mementoは、複数のLLMプロバイダを容易に構成可能な適応性を備えています。",
|
||||
|
||||
f1_title: "セマンティック意味検索",
|
||||
f1_desc: "単なるキーワード検索はもう不要。記述された文脈、概念そのものを捉えて、過去の関連メモを一瞬で検索します。",
|
||||
@@ -571,7 +571,7 @@ const JA: LangDict = {
|
||||
price_popular: "人気プラン",
|
||||
price_free_name: "ベーシック",
|
||||
price_free_price: "無料",
|
||||
price_free_desc: "Momentoの見事な基礎機能をすぐにお試しいただけます。",
|
||||
price_free_desc: "Mementoの見事な基礎機能をすぐにお試しいただけます。",
|
||||
price_pro_name: "プロ",
|
||||
price_pro_price: "¥1,480",
|
||||
price_pro_desc: "ライター、学習者、研究者、コンサルタントの方へ最適。",
|
||||
@@ -595,7 +595,7 @@ const JA: LangDict = {
|
||||
|
||||
byok_badge: "オープン構想",
|
||||
byok_title: "APIキー持ち込み(BYOK)",
|
||||
byok_desc: "OpenAI、Anthropic、Googleなどの既存APIキーをお持ちですか?Momentoに直接バインドすれば、従量課金のみで任意の極限モデルを完全に制限なしで無制限にご利用いただけます。",
|
||||
byok_desc: "OpenAI、Anthropic、Googleなどの既存APIキーをお持ちですか?Mementoに直接バインドすれば、従量課金のみで任意の極限モデルを完全に制限なしで無制限にご利用いただけます。",
|
||||
byok_col1_title: "ロックイン縛りゼロ",
|
||||
byok_col1_desc: "好みのプロバイダやエンジンへ一瞬でコンフィグを切り替えられます。",
|
||||
byok_col2_title: "中抜きマージン排除",
|
||||
@@ -604,10 +604,10 @@ const JA: LangDict = {
|
||||
|
||||
final_cta_title: "あなたの第二の有機的な脳を",
|
||||
final_cta_title_italic: "今すぐ起動させましょう。",
|
||||
final_cta_desc: "最先端デザインと人工知能を極限まで融合させたMomento。すでに数千人のアカデミアやデザイナーが思考のスケールを始めています。",
|
||||
final_cta_button: "Momentoを起動する",
|
||||
final_cta_desc: "最先端デザインと人工知能を極限まで融合させたMemento。すでに数千人のアカデミアやデザイナーが思考のスケールを始めています。",
|
||||
final_cta_button: "Mementoを起動する",
|
||||
|
||||
eco_badge: "Momentoエコシステム",
|
||||
eco_badge: "Mementoエコシステム",
|
||||
eco_title_1: "ドキュメントローカル翻訳。",
|
||||
eco_title_2: "完璧な構造維持。",
|
||||
eco_desc: "グラフ、入れ子、アライメント、レイアウト、タイトル座標のすべてを完璧に保持したまま、言語領域を変換します。アイデアを瞬時にグローバルへ。",
|
||||
@@ -693,7 +693,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
carnet: "Database & Books",
|
||||
date: "26 Oct 2024",
|
||||
tags: ["Relational", "Rollup", "Blocks"],
|
||||
content: `# H2 Relation and Rollup\n\nCe document démontre la puissance du modèle relationnel de Momento.\n\n## 1. Modèle Relationnel\nVous pouvez lier des auteurs à leurs œuvres pour comptabiliser dynamiquement les entrées grâce à notre système de Rollups sémantiques.`,
|
||||
content: `# H2 Relation and Rollup\n\nCe document démontre la puissance du modèle relationnel de Memento.\n\n## 1. Modèle Relationnel\nVous pouvez lier des auteurs à leurs œuvres pour comptabiliser dynamiquement les entrées grâce à notre système de Rollups sémantiques.`,
|
||||
stats: { words: 124, lines: 18, equations: 1, graphs: 4, images: 3 }
|
||||
},
|
||||
{
|
||||
@@ -702,7 +702,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
carnet: "Mathematical & Geometrical",
|
||||
date: "24 Oct 2024",
|
||||
tags: ["LaTeX", "Gantt", "Flowcharts"],
|
||||
content: `# Block-Style Math & Formulas\n\nMomento supporte les équations complexes de type LaTeX et les diagrammes sémantiques directement intégrés sous forme de blocs.\n\n$$\\Phi = \\frac{1 + \\sqrt{5}}{2}$$\n\n$$\\Delta Carbon = E_{béton} - E_{CLT} = 410 \\text{ kg } CO_2/m^3$$`,
|
||||
content: `# Block-Style Math & Formulas\n\nMemento supporte les équations complexes de type LaTeX et les diagrammes sémantiques directement intégrés sous forme de blocs.\n\n$$\\Phi = \\frac{1 + \\sqrt{5}}{2}$$\n\n$$\\Delta Carbon = E_{béton} - E_{CLT} = 410 \\text{ kg } CO_2/m^3$$`,
|
||||
stats: { words: 91, lines: 12, equations: 2, graphs: 4, images: 1 }
|
||||
},
|
||||
{
|
||||
@@ -855,7 +855,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
<div className="w-10 h-10 bg-ink flex items-center justify-center rounded-xl shadow-lg rotate-3 group hover:rotate-0 transition-transform cursor-pointer">
|
||||
<span className="text-paper font-serif text-2xl font-bold">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Momento</span>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Memento</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-10">
|
||||
@@ -1383,10 +1383,10 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
</button>
|
||||
<p className="text-stone-605">
|
||||
{lang === 'fr'
|
||||
? "Momento intègre des équations mathématiques pures et des diagrammes sémantiques ou Gantt de manière entièrement nativisée sous forme de blocs WYSIWYG."
|
||||
? "Memento intègre des équations mathématiques pures et des diagrammes sémantiques ou Gantt de manière entièrement nativisée sous forme de blocs WYSIWYG."
|
||||
: lang === 'ja'
|
||||
? "Momentoは本格的なLaTeX数式および概念関係フローチャートをWYSIWYGブロックとして極めて滑らかに表示・調整可能です。"
|
||||
: "Momento integrates highly stylized Mathematical LaTeX formulas and semantic/Gantt flowcharts directly as interactive WYSIWYG blocks."}
|
||||
? "Mementoは本格的なLaTeX数式および概念関係フローチャートをWYSIWYGブロックとして極めて滑らかに表示・調整可能です。"
|
||||
: "Memento integrates highly stylized Mathematical LaTeX formulas and semantic/Gantt flowcharts directly as interactive WYSIWYG blocks."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1475,9 +1475,9 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
</button>
|
||||
<p className="text-stone-605">
|
||||
{lang === 'fr'
|
||||
? "Le noyau de Momento est conçu pour gérer d'immenses documents textuels (plus d'un million de mots) avec une latence quasi nulle en virtualisant les sous-structures."
|
||||
? "Le noyau de Memento est conçu pour gérer d'immenses documents textuels (plus d'un million de mots) avec une latence quasi nulle en virtualisant les sous-structures."
|
||||
: lang === 'ja'
|
||||
? "Momentoの超高速仮想ドキュメントレンダリングは、合計100万語を越える膨大な書籍や論文データベースでも、表示遅延なく高速に動作・ブロック分割制御可能です。"
|
||||
? "Mementoの超高速仮想ドキュメントレンダリングは、合計100万語を越える膨大な書籍や論文データベースでも、表示遅延なく高速に動作・ブロック分割制御可能です。"
|
||||
: "Its lightweight framework allows seamlessly displaying and editing files sizing up to 1,000,000 words without single-frame drops thanks to block virtualization."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1544,10 +1544,10 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
Markdown
|
||||
</div>
|
||||
{activeDemoIdx === 0 && (
|
||||
`# H2 Relation and Rollup\n\nCe document démontre la puissance du modèle relationnel de Momento.\n\n## 1. Modèle Relationnel\nLier des auteurs à leurs œuvres pour comptabiliser dynamiquement.\n\n[DATABASE id="authors-works" view="table"]\n\n## Template\nTemplates can access values via syntax: .action { .field }`
|
||||
`# H2 Relation and Rollup\n\nCe document démontre la puissance du modèle relationnel de Memento.\n\n## 1. Modèle Relationnel\nLier des auteurs à leurs œuvres pour comptabiliser dynamiquement.\n\n[DATABASE id="authors-works" view="table"]\n\n## Template\nTemplates can access values via syntax: .action { .field }`
|
||||
)}
|
||||
{activeDemoIdx === 1 && (
|
||||
`# Block-Style Math & Formulas\n\nMomento supporte LaTeX.\n\n$$ \\Phi = \\frac{1 + \\sqrt{5}}{2} $$\n\n$$ \\Delta Carbon = E_béton - E_CLT = 410 $$`
|
||||
`# Block-Style Math & Formulas\n\nMemento supporte LaTeX.\n\n$$ \\Phi = \\frac{1 + \\sqrt{5}}{2} $$\n\n$$ \\Delta Carbon = E_béton - E_CLT = 410 $$`
|
||||
)}
|
||||
{activeDemoIdx === 2 && (
|
||||
`# Large Document Virtualization\n\nLe moteur supporte de longs documents.\n\n## Zoom-In de bloc\nIsoler un bloc spécifique pour se concentrer.`
|
||||
@@ -1566,7 +1566,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
{editorMode === 'html' && (
|
||||
<div className="space-y-2.5 font-mono text-[9px]">
|
||||
<div className="bg-slate-50 border p-3 rounded-lg text-emerald-800 space-y-2 select-all leading-snug">
|
||||
<div>{`<!-- Momento Symmetrical DOM Tree map -->`}</div>
|
||||
<div>{`<!-- Memento Symmetrical DOM Tree map -->`}</div>
|
||||
<div>{`<div class="note-container font-serif" id="momento-doc-${activeDemoIdx}">`}</div>
|
||||
<div className="pl-3">{`<h2 class="title text-lg border-b">${activeDemoIdx === -1 ? "Synthèse" : realNotes[activeDemoIdx].title}</h2>`}</div>
|
||||
<div className="pl-3">{`<div class="section-meta flex text-[8px] uppercase">`}</div>
|
||||
@@ -2385,7 +2385,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ onEnter, onLogin, onRe
|
||||
<div className="w-8 h-8 bg-ink flex items-center justify-center rounded-lg">
|
||||
<span className="text-paper font-serif text-lg font-bold">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Momento</span>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</div>
|
||||
<p className="text-sm text-concrete font-light max-w-xs leading-relaxed">{t('footer_desc')}</p>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const LandingPageV2: React.FC<LandingPageV2Props> = ({
|
||||
onSwitchVersion
|
||||
}) => {
|
||||
|
||||
// Real world Momento note seeds to match constants.ts exactly
|
||||
// Real world Memento note seeds to match constants.ts exactly
|
||||
const realNotes = [
|
||||
{
|
||||
id: 'n1',
|
||||
@@ -194,7 +194,7 @@ $$\\Omega(x) = \\int_{0}^{\\Lambda} e^{-k \\cdot x} \\cdot dx$$
|
||||
<span className="text-paper font-serif text-2xl font-bold">M</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Momento</span>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Memento</span>
|
||||
<span className="text-[9px] font-black text-accent uppercase tracking-widest block font-mono -mt-1.5 pl-0.5">Second Cerveau</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,7 +242,7 @@ $$\\Omega(x) = \\int_{0}^{\\Lambda} e^{-k \\cdot x} \\cdot dx$$
|
||||
<section className="relative pt-32 pb-24 px-6 md:px-12 max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-stretch">
|
||||
|
||||
{/* Left Hero Sidebar: Information on Momento's Core Features */}
|
||||
{/* Left Hero Sidebar: Information on Memento's Core Features */}
|
||||
<div className="lg:col-span-5 flex flex-col justify-between space-y-6 text-left">
|
||||
<div className="space-y-5">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-accent/10 border border-accent/20 text-accent text-[9px] font-bold font-mono tracking-widest uppercase">
|
||||
@@ -256,7 +256,7 @@ $$\\Omega(x) = \\int_{0}^{\\Lambda} e^{-k \\cdot x} \\cdot dx$$
|
||||
</h1>
|
||||
|
||||
<p className="text-stone-600 text-sm leading-relaxed font-light">
|
||||
Momento rassemble vos notes, vos formules mathématiques et votre logique dans une structure d'apprentissage offline d'une flexibilité absolue. Évitez les abonnements ruineux en apportant votre clé personnelle (BYOK) ou utilisez l'environnement de manière 100% autonome et sécurisée.
|
||||
Memento rassemble vos notes, vos formules mathématiques et votre logique dans une structure d'apprentissage offline d'une flexibilité absolue. Évitez les abonnements ruineux en apportant votre clé personnelle (BYOK) ou utilisez l'environnement de manière 100% autonome et sécurisée.
|
||||
</p>
|
||||
|
||||
{/* Guided Feature Tabs Selector: Directly gives info on what the user can do */}
|
||||
@@ -320,7 +320,7 @@ $$\\Omega(x) = \\int_{0}^{\\Lambda} e^{-k \\cdot x} \\cdot dx$$
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Hero Column: True 1-to-1 Interactive Representation of Momento Workspace */}
|
||||
{/* Right Hero Column: True 1-to-1 Interactive Representation of Memento Workspace */}
|
||||
<div className="lg:col-span-7 w-full flex flex-col justify-between">
|
||||
<div className="w-full bg-[#1c1c1c] rounded-[24px] border border-ink/40 p-3 shadow-2xl relative overflow-hidden flex flex-col justify-between">
|
||||
|
||||
@@ -863,7 +863,7 @@ $$\\Omega(x) = \\int_{0}^{\\Lambda} e^{-k \\cdot x} \\cdot dx$$
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.3em] text-[#A47148] font-mono block">Respect Souverain des Écrits</span>
|
||||
<h2 className="text-3xl font-serif text-[#1C1C1C] tracking-tight leading-tight">Aucun abonnement IA obligatoire grâce au modèle "BYOK".</h2>
|
||||
<p className="text-sm font-light text-stone-600 leading-relaxed">
|
||||
La plupart des applications cloud d'intelligence artificielle re-facturent de lourdes marges commerciales sur vos écrits. Momento change les règles du jeu :
|
||||
La plupart des applications cloud d'intelligence artificielle re-facturent de lourdes marges commerciales sur vos écrits. Memento change les règles du jeu :
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
@@ -916,7 +916,7 @@ $$\\Omega(x) = \\int_{0}^{\\Lambda} e^{-k \\cdot x} \\cdot dx$$
|
||||
<div className="max-w-6xl mx-auto space-y-16">
|
||||
<div className="max-w-xl mx-auto text-center space-y-3">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-[#A47148] font-mono block">Les Atouts Pratiques</span>
|
||||
<h2 className="text-3xl font-serif text-[#1C1C1C] tracking-tight leading-tight">Pourquoi utiliser Momento ?</h2>
|
||||
<h2 className="text-3xl font-serif text-[#1C1C1C] tracking-tight leading-tight">Pourquoi utiliser Memento ?</h2>
|
||||
<p className="text-xs font-light text-stone-500">Un écosystème conçu de bout en bout pour l'autonomie et la productivité.</p>
|
||||
</div>
|
||||
|
||||
@@ -961,7 +961,7 @@ $$\\Omega(x) = \\int_{0}^{\\Lambda} e^{-k \\cdot x} \\cdot dx$$
|
||||
<section className="py-20 text-center bg-[#FAF9F5]/70 select-none">
|
||||
<div className="max-w-xl mx-auto space-y-5 px-6">
|
||||
<h3 className="text-2xl font-serif text-[#1C1C1C] tracking-tight">Structurer vos notes et vos pensées.</h3>
|
||||
<p className="text-xs text-stone-500 font-light">Accédez directement à l'espace de travail fluide de Momento, sans processus contraignant.</p>
|
||||
<p className="text-xs text-stone-500 font-light">Accédez directement à l'espace de travail fluide de Memento, sans processus contraignant.</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={onEnter}
|
||||
|
||||
@@ -103,14 +103,14 @@ const FR: LangDict = {
|
||||
hero_badge: "Amplifié par l'intelligence collective locale & cloud",
|
||||
hero_title_1: "Votre second cerveau,",
|
||||
hero_title_italic: "enfin amplifié.",
|
||||
hero_desc: "Momento est un écosystème d'écriture intelligent en temps réel. Naviguez dans vos pensées via un graphe de connaissances 3D, étudiez avec répétition espacée, et déléguez vos recherches à 6 agents spécialisés autonomes.",
|
||||
hero_desc: "Memento est un écosystème d'écriture intelligent en temps réel. Naviguez dans vos pensées via un graphe de connaissances 3D, étudiez avec répétition espacée, et déléguez vos recherches à 6 agents spécialisés autonomes.",
|
||||
hero_cta_start: "S'inscrire gratuitement",
|
||||
hero_cta_features: "Explorer l'espace",
|
||||
|
||||
features_badge: "CAPACITÉS DU SYSTÈME",
|
||||
features_title: "Une sémantique naturelle,",
|
||||
features_subtitle: "fondée sur vos apprentissages.",
|
||||
features_desc: "Momento orchestre vos idées grâce à une architecture locale cryptée.",
|
||||
features_desc: "Memento orchestre vos idées grâce à une architecture locale cryptée.",
|
||||
|
||||
feature_1_title: "Recherche Sémantique Hybride",
|
||||
feature_1_desc: "Trouvez vos concepts au-delà des synonymes. Notre moteur hybride vectoriel comprend intrinsèquement l'intention de vos rédactions.",
|
||||
@@ -166,14 +166,14 @@ const EN: LangDict = {
|
||||
hero_badge: "Amplified by local & cloud collective intelligence",
|
||||
hero_title_1: "Your second brain,",
|
||||
hero_title_italic: "finally amplified.",
|
||||
hero_desc: "Momento is a real-time intelligent writing ecosystem. Navigate your thoughts via an interactive knowledge graph, study with spaced-repetition active recall, and delegate tasks to 6 autonomous specialist agents.",
|
||||
hero_desc: "Memento is a real-time intelligent writing ecosystem. Navigate your thoughts via an interactive knowledge graph, study with spaced-repetition active recall, and delegate tasks to 6 autonomous specialist agents.",
|
||||
hero_cta_start: "Start for free",
|
||||
hero_cta_features: "Explore workspace",
|
||||
|
||||
features_badge: "SYSTEM CAPABILITIES",
|
||||
features_title: "Natural semantics,",
|
||||
features_subtitle: "grounded in your knowledge.",
|
||||
features_desc: "Momento orchestrates your thoughts through an encrypted local architecture.",
|
||||
features_desc: "Memento orchestrates your thoughts through an encrypted local architecture.",
|
||||
|
||||
feature_1_title: "Hybrid Semantic Search",
|
||||
feature_1_desc: "Search concepts, not just keywords. Our hybrid vector engine understands the core semantic intention of your notes.",
|
||||
@@ -229,14 +229,14 @@ const JA: LangDict = {
|
||||
hero_badge: "ローカル&クラウド融合人工知能により拡張",
|
||||
hero_title_1: "あなたの第二の脳は、",
|
||||
hero_title_italic: "ついに具現化する。",
|
||||
hero_desc: "Momento(モメント)は、リアルタイムでアイデア同士が結合する最先端ナレッジベース。3D知識グラフ、間隔反復フラッシュカード、そして6体の自律型AIエージェントが、あなたの思考を強力に加速します。",
|
||||
hero_desc: "Memento(モメント)は、リアルタイムでアイデア同士が結合する最先端ナレッジベース。3D知識グラフ、間隔反復フラッシュカード、そして6体の自律型AIエージェントが、あなたの思考を強力に加速します。",
|
||||
hero_cta_start: "無料で体験する",
|
||||
hero_cta_features: "機能を体験する",
|
||||
|
||||
features_badge: "システム性能",
|
||||
features_title: "自然言語に根ざした、",
|
||||
features_subtitle: "あなただけのコンテキスト。",
|
||||
features_desc: "Momentoは、ローカル暗号化アーキテクチャを通じて、あなたの機密思考システムを管理します。",
|
||||
features_desc: "Mementoは、ローカル暗号化アーキテクチャを通じて、あなたの機密思考システムを管理します。",
|
||||
|
||||
feature_1_title: "ハイブリッド意味検索",
|
||||
feature_1_desc: "単なるキーワード一致の域を超え、意図を汲み取るベクトル意味検索。関連する記述を一瞬で発掘します。",
|
||||
@@ -415,7 +415,7 @@ export const LandingPageV3: React.FC<LandingPageV3Props> = ({
|
||||
<span className="text-paper font-serif text-2xl font-bold">M</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Momento</span>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Memento</span>
|
||||
<span className="text-[9px] font-black text-accent uppercase tracking-widest block font-mono -mt-1.5 pl-0.5">V3 Multilingue</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1109,7 +1109,7 @@ export const LandingPageV3: React.FC<LandingPageV3Props> = ({
|
||||
|
||||
{/* Footer copyright */}
|
||||
<footer className="py-12 px-8 bg-[#FAF9F5] text-center border-t border-black/[0.04] text-[10px] font-mono text-stone-400 uppercase tracking-widest select-none">
|
||||
<div>Momento Systems V3 — Chiffrement local garanti.</div>
|
||||
<div>Memento Systems V3 — Chiffrement local garanti.</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -602,7 +602,7 @@ export const SearchModal: React.FC<SearchModalProps> = ({
|
||||
|
||||
<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>
|
||||
<span>Memento Search OS v2.3</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -9,7 +9,7 @@ export const BillingTab: React.FC = () => {
|
||||
name: 'Plan Basic',
|
||||
price: 'Gratuit',
|
||||
period: '',
|
||||
description: 'Pour découvrir la magie de Momento.',
|
||||
description: 'Pour découvrir la magie de Memento.',
|
||||
features: [
|
||||
'100 Notes max',
|
||||
'3 Carnets',
|
||||
@@ -119,7 +119,7 @@ export const BillingTab: React.FC = () => {
|
||||
</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>
|
||||
<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 Memento.</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>
|
||||
|
||||
@@ -91,7 +91,7 @@ Epic goal: B2B legal blockers for EU buyers. Cookie consent is the **first** Epi
|
||||
|
||||
### Cookie classification (implement exactly)
|
||||
|
||||
| Category | Examples in Momento | Consent required |
|
||||
| Category | Examples in Memento | Consent required |
|
||||
|----------|---------------------|------------------|
|
||||
| **Strictly necessary** | NextAuth session, CSRF (if any), `user-language` cookie, theme/direction localStorage, consent record itself | No — always on |
|
||||
| **Analytics** | Future PostHog/Umami/Plausible events, product funnel, feature flags tied to identity | Yes — opt-in |
|
||||
|
||||
@@ -138,10 +138,10 @@ URL.revokeObjectURL(url)
|
||||
|
||||
### Thème PPTX
|
||||
|
||||
Cohérent avec l'identité visuelle Momento :
|
||||
Cohérent avec l'identité visuelle Memento :
|
||||
- `bg: F2F0E9` — fond papier
|
||||
- `primary: 1C1C1C` — noir ardoise
|
||||
- `accent: A47148` — brand-accent Momento
|
||||
- `accent: A47148` — brand-accent Memento
|
||||
- `secondary: D4A373` — ocre clair
|
||||
|
||||
### Fichiers clés existants
|
||||
|
||||
@@ -39,7 +39,7 @@ Note TipTap (JSON) → Export Markdown → Re-import → JSON identique (byte-fo
|
||||
|
||||
**Inconvénients :**
|
||||
- Certaines extensions sont sous licence TipTap Pro ($149/mois)
|
||||
- Les nœuds custom de Momento (`liveBlock`, `structuredViewBlock`) nécessitent des serializers manuels
|
||||
- Les nœuds custom de Memento (`liveBlock`, `structuredViewBlock`) nécessitent des serializers manuels
|
||||
- Round-trip parfait impossible pour ces nœuds (dégradation gracieuse : placeholder HTML comment)
|
||||
|
||||
**Implémentation :**
|
||||
@@ -104,7 +104,7 @@ import markdownit from 'markdown-it'
|
||||
const momentoSerializer = new MarkdownSerializer(
|
||||
{
|
||||
...defaultMarkdownSerializer.nodes,
|
||||
// Nœuds Momento custom
|
||||
// Nœuds Memento custom
|
||||
liveBlock: (state, node) => {
|
||||
state.write(`<!-- live-block: ${node.attrs.sourceNoteId}#${node.attrs.blockId} -->`)
|
||||
state.closeBlock(node)
|
||||
@@ -148,7 +148,7 @@ Raisons :
|
||||
1. Intégration en **1 journée** dans l'éditeur existant
|
||||
2. Couvre 95% des cas d'usage (texte, listes, headings, code, tables, tâches)
|
||||
3. Le `transformPastedText: true` résout aussi un bug UX courant (coller du Markdown brut)
|
||||
4. Les nœuds Momento non-supportés sont préservés via commentaires HTML (dégradation gracieuse)
|
||||
4. Les nœuds Memento non-supportés sont préservés via commentaires HTML (dégradation gracieuse)
|
||||
|
||||
### Long terme : **Option B** en complément
|
||||
|
||||
@@ -171,7 +171,7 @@ Pour les cas avancés (export propre des nœuds custom, CI de round-trip byte-fo
|
||||
|
||||
### Ce qui est exclu (post-beta)
|
||||
|
||||
- Round-trip byte-for-byte des nœuds Momento custom
|
||||
- Round-trip byte-for-byte des nœuds Memento custom
|
||||
- Édition native en mode source Markdown (raw text editor)
|
||||
- Sync bidirectionnelle temps réel Markdown ↔ TipTap
|
||||
|
||||
@@ -197,7 +197,7 @@ Pour les cas avancés (export propre des nœuds custom, CI de round-trip byte-fo
|
||||
**Then** je télécharge un fichier `.md` avec le contenu fidèlement sérialisé
|
||||
|
||||
**Given** je copie du Markdown depuis un éditeur externe
|
||||
**When** je colle dans l'éditeur Momento
|
||||
**When** je colle dans l'éditeur Memento
|
||||
**Then** le Markdown est automatiquement converti en blocs TipTap correspondants (pas du texte brut)
|
||||
|
||||
**Given** j'importe un fichier `.md`
|
||||
|
||||
@@ -9,11 +9,11 @@ inputDocuments:
|
||||
- memento-note/docs/saas-deployment-prep.md
|
||||
---
|
||||
|
||||
# Momento - Epic Breakdown
|
||||
# Memento - Epic Breakdown
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides the complete epic and story breakdown for Momento, focusing strictly on the **Commercial and AI Delta** required to launch the V3 product. Momento is a **Brownfield** project. The baseline functionality (Basic User Auth, Workspaces, CRUD Rich-Text Notes, pgvector database) already exists and is considered out-of-scope for these epics.
|
||||
This document provides the complete epic and story breakdown for Memento, focusing strictly on the **Commercial and AI Delta** required to launch the V3 product. Memento is a **Brownfield** project. The baseline functionality (Basic User Auth, Workspaces, CRUD Rich-Text Notes, pgvector database) already exists and is considered out-of-scope for these epics.
|
||||
|
||||
## Requirements Inventory
|
||||
|
||||
@@ -223,7 +223,7 @@ So that I retain full control over my data privacy.
|
||||
|
||||
**Given** I trigger an AI feature that processes my notes or PDFs
|
||||
**When** the request is initiated
|
||||
**Then** (NFR-GDPR4) explicit consent is logged before the data leaves the Momento infrastructure.
|
||||
**Then** (NFR-GDPR4) explicit consent is logged before the data leaves the Memento infrastructure.
|
||||
|
||||
### Story 4.5: EU Data Residency Configuration
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Fonctionnalités IA — Momento
|
||||
# Fonctionnalités IA — Memento
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Guide utilisateur Momento
|
||||
# Guide utilisateur Memento
|
||||
|
||||
Documentation produit illustrée du SaaS **Momento** — second cerveau augmenté par l’IA (notes, recherche sémantique, agents, brainstorm collaboratif, BYOK).
|
||||
Documentation produit illustrée du SaaS **Memento** — second cerveau augmenté par l’IA (notes, recherche sémantique, agents, brainstorm collaboratif, BYOK).
|
||||
|
||||
> **Sources internes** : ce guide synthétise le [PRD](../prd.md), les [fonctionnalités IA](../fonctionnalites-ia.md), la [doc brainstorm](../../memento-note/docs/brainstorm-documentation.md) et le [GUIDE technique](../../GUIDE.md) (installation / admin).
|
||||
|
||||
@@ -24,7 +24,7 @@ Documentation produit illustrée du SaaS **Momento** — second cerveau augment
|
||||
|
||||
## 1. Vue d’ensemble
|
||||
|
||||
Momento est une application de prise de notes qui combine :
|
||||
Memento est une application de prise de notes qui combine :
|
||||
|
||||
- **Organisation** : carnets, labels, grille masonry, archive, corbeille, partage.
|
||||
- **Recherche sémantique** : trouver par idée, pas seulement par mot-clé (vecteurs + plein texte).
|
||||
@@ -72,7 +72,7 @@ La landing publique présente la proposition de valeur avant inscription.
|
||||
|
||||

|
||||
|
||||
Message clé : Momento relie, analyse et développe vos idées avec **6 types d’agents IA** et une **recherche sémantique** avancée. Exemple produit : *Memory Echo* qui signale un lien avec un projet passé.
|
||||
Message clé : Memento relie, analyse et développe vos idées avec **6 types d’agents IA** et une **recherche sémantique** avancée. Exemple produit : *Memory Echo* qui signale un lien avec un projet passé.
|
||||
|
||||
### Capacités IA
|
||||
|
||||
@@ -111,7 +111,7 @@ Brainstorming radial temps réel : génération par vagues, collaboration (curse
|
||||
|
||||

|
||||
|
||||
Si vous avez déjà des clés **OpenAI**, **Anthropic** ou **Google**, vous les connectez à Momento : pas de plafond de crédits imposé par la plateforme, facturation directe chez le fournisseur, changement de provider en un clic.
|
||||
Si vous avez déjà des clés **OpenAI**, **Anthropic** ou **Google**, vous les connectez à Memento : pas de plafond de crédits imposé par la plateforme, facturation directe chez le fournisseur, changement de provider en un clic.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ includedFiles:
|
||||
# Implementation Readiness Assessment Report
|
||||
|
||||
**Date:** 2026-05-14
|
||||
**Project:** Momento
|
||||
**Project:** Memento
|
||||
|
||||
## PRD Analysis
|
||||
|
||||
|
||||
16
docs/prd.md
16
docs/prd.md
@@ -29,19 +29,19 @@ inputDocuments:
|
||||
workflowType: 'prd'
|
||||
---
|
||||
|
||||
# Product Requirements Document - Momento
|
||||
# Product Requirements Document - Memento
|
||||
|
||||
**Author:** User
|
||||
**Date:** 2026-05-14
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Momento Note democratizes access to an AI-augmented digital memory for a dual audience: self-taught individuals and enterprise R&D departments. As a next-generation Personal Knowledge Management system, it transforms note-taking from passive storage into an active, intelligent partner. By integrating vector-based semantic search and an ecosystem of autonomous agents, Momento automatically surfaces hidden connections, remembers forgotten insights, and accelerates knowledge work.
|
||||
Memento Note democratizes access to an AI-augmented digital memory for a dual audience: self-taught individuals and enterprise R&D departments. As a next-generation Personal Knowledge Management system, it transforms note-taking from passive storage into an active, intelligent partner. By integrating vector-based semantic search and an ecosystem of autonomous agents, Memento automatically surfaces hidden connections, remembers forgotten insights, and accelerates knowledge work.
|
||||
|
||||
### What Makes This Special
|
||||
|
||||
Momento's true power lies in its seamless blend of advanced AI tools and innovative financial architecture:
|
||||
- **Autonomous Ecosystem:** Moving beyond a smart notepad, Momento acts as an autonomous "Second Brain." It deploys specialized agents (Scraper, Researcher, Monitor) and native productivity tools like Document Parsing (Chat-with-PDF) and Automated Task Extraction directly within the user's workspace.
|
||||
Memento's true power lies in its seamless blend of advanced AI tools and innovative financial architecture:
|
||||
- **Autonomous Ecosystem:** Moving beyond a smart notepad, Memento acts as an autonomous "Second Brain." It deploys specialized agents (Scraper, Researcher, Monitor) and native productivity tools like Document Parsing (Chat-with-PDF) and Automated Task Extraction directly within the user's workspace.
|
||||
- **Collaborative Brainstorming:** A real-time, D3-powered radial graph canvas allows users to generate, expand, and structure AI-driven ideas collaboratively.
|
||||
- **Sustainable "Host-Pays" Billing & BYOK:** Memento resolves the SaaS AI cost paradox through intelligent smart routing (defaulting to highly optimized models like DeepSeek V4 Flash) and a Bring-Your-Own-Key (BYOK) architecture that eliminates AI costs for power users. The innovative Freemium "AI Discovery Pack" provides lifetime access limits rather than restrictive monthly quotas, delivering an immediate "Aha!" moment without friction.
|
||||
|
||||
@@ -100,7 +100,7 @@ Momento's true power lies in its seamless blend of advanced AI tools and innovat
|
||||
|
||||
### 1. The Power User (BYOK & Autonomous Ecosystem)
|
||||
**Persona:** Alex, an independent data science researcher analyzing dense PDFs, frustrated by arbitrary SaaS API limits.
|
||||
**Opening Scene:** Alex discovers Momento through a watermark on a shared presentation. They sign up and are granted the Freemium "AI Discovery Pack."
|
||||
**Opening Scene:** Alex discovers Memento through a watermark on a shared presentation. They sign up and are granted the Freemium "AI Discovery Pack."
|
||||
**Rising Action:** Alex uploads a complex 50-page PDF and uses the Chat-with-PDF feature to extract methodologies. The AI's responses are rapid and accurate. Because Alex is doing heavy research, they quickly exhaust their Discovery Pack token limits.
|
||||
**Climax (The "Aha!" Moment):** Instead of hitting a hard paywall that locks them out, Memento elegantly prompts them to input their own LLM API key (BYOK). Alex pastes their DeepSeek key, and instantly, they are back to querying at near-zero marginal cost, entirely avoiding a rigid $20/mo subscription.
|
||||
**Resolution:** Alex fully adopts Memento as their "Second Brain", deploying autonomous Scraper agents to monitor new Arxiv papers directly into their semantic search index.
|
||||
@@ -148,13 +148,13 @@ These journeys reveal the following critical capabilities we must build:
|
||||
|
||||
### Detected Innovation Areas
|
||||
|
||||
1. **Financial Architecture (The "Host-Pays" + BYOK Model):** Momento solves the SaaS AI unit economics paradox. By shifting all collaborative AI generation costs exclusively to the session host's quotas—while simultaneously offering a zero-margin Bring-Your-Own-Key (BYOK) escape hatch—the platform eliminates the traditional per-seat LLM paywall friction that stifles viral growth.
|
||||
1. **Financial Architecture (The "Host-Pays" + BYOK Model):** Memento solves the SaaS AI unit economics paradox. By shifting all collaborative AI generation costs exclusively to the session host's quotas—while simultaneously offering a zero-margin Bring-Your-Own-Key (BYOK) escape hatch—the platform eliminates the traditional per-seat LLM paywall friction that stifles viral growth.
|
||||
2. **Autonomous Agent Ecosystem Native to PKM:** Integrating Scraper, Researcher, and Monitor agents directly into the note environment. Instead of users manually pulling data into their notes, the "Second Brain" actively structures and retrieves knowledge via vector-based semantic search.
|
||||
3. **Radial Graph-Based AI Brainstorming:** Moving away from linear chat interfaces (like standard ChatGPT) to a multi-directional, real-time D3 radial graph, where ideas expand outwards in "Waves" (Variations, Analogies, Disruptions).
|
||||
|
||||
### Market Context & Competitive Landscape
|
||||
|
||||
Traditional PKM tools (like Notion or Obsidian) either charge heavy flat-rate AI add-ons ($10-$20/mo) or require highly technical, fragile plugin setups for local models. Conversely, standard whiteboard tools (Miro, FigJam) offer AI generation but lack the deep semantic connection to a user's personal knowledge base. Momento occupies the blue ocean between an enterprise collaboration whiteboard and an autonomous research assistant.
|
||||
Traditional PKM tools (like Notion or Obsidian) either charge heavy flat-rate AI add-ons ($10-$20/mo) or require highly technical, fragile plugin setups for local models. Conversely, standard whiteboard tools (Miro, FigJam) offer AI generation but lack the deep semantic connection to a user's personal knowledge base. Memento occupies the blue ocean between an enterprise collaboration whiteboard and an autonomous research assistant.
|
||||
|
||||
### Validation Approach
|
||||
|
||||
@@ -171,7 +171,7 @@ Traditional PKM tools (like Notion or Obsidian) either charge heavy flat-rate AI
|
||||
## SaaS Web Application Specific Requirements
|
||||
|
||||
### Project-Type Overview
|
||||
Momento is a B2B and B2C SaaS platform serving as a multi-tenant personal knowledge management system. It requires complex state synchronization, robust role-based access controls for collaborative sessions, and an advanced hybrid billing architecture.
|
||||
Memento is a B2B and B2C SaaS platform serving as a multi-tenant personal knowledge management system. It requires complex state synchronization, robust role-based access controls for collaborative sessions, and an advanced hybrid billing architecture.
|
||||
|
||||
### Technical Architecture Considerations
|
||||
- **Frontend:** React + Next.js App Router, using D3.js for the Brainstorm radial graph and React Query for state management.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Memento — Spécification Technique & Design du Système de Parrainage (Referral)
|
||||
|
||||
Ce document présente l'architecture complète d'un système de parrainage (referral) natif pour **Momento**, intégré à notre base de données PostgreSQL (Prisma) et à Stripe.
|
||||
Ce document présente l'architecture complète d'un système de parrainage (referral) natif pour **Memento**, intégré à notre base de données PostgreSQL (Prisma) et à Stripe.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectifs du Système
|
||||
|
||||
1. **Viralité (PLG)** : Encourager les utilisateurs existants à partager Momento pour acquérir de nouveaux clients sans budget publicitaire (CAC proche de 0).
|
||||
1. **Viralité (PLG)** : Encourager les utilisateurs existants à partager Memento pour acquérir de nouveaux clients sans budget publicitaire (CAC proche de 0).
|
||||
2. **Double Récompense (Win-Win)** :
|
||||
- **Le Filleul (Invité)** obtient une réduction immédiate lors de son premier abonnement (ex. `-10 % sur son abonnement PRO`).
|
||||
- **Le Parrain (Hôte)** obtient une récompense lors du premier paiement de son filleul (ex. **1 mois gratuit** appliqué directement sur sa prochaine facture Stripe, ou **+100 crédits IA** récurrents).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# generated: 2026-05-14T16:06:50Z
|
||||
# last_updated: 2026-05-23T14:25:15Z
|
||||
# project: Momento
|
||||
# project: Memento
|
||||
# project_key: NOKEY
|
||||
# tracking_system: file-system
|
||||
# story_location: docs
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
generated: 2026-05-14T16:06:50Z
|
||||
last_updated: 2026-05-23T20:03:48Z
|
||||
project: Momento
|
||||
project: Memento
|
||||
project_key: NOKEY
|
||||
tracking_system: file-system
|
||||
story_location: docs
|
||||
|
||||
@@ -62,7 +62,7 @@ This story transforms the admin console into a **production-ready management das
|
||||
- **When** I scroll below the metric cards
|
||||
- **Then** I see a 7-day area chart showing daily AI requests (sourced from `UsageLog` grouped by `periodStart` day)
|
||||
- **And** I see a user growth line chart (users created per day, last 30 days)
|
||||
- **And** charts use Recharts (already a dependency — verify) with the Momento color palette:
|
||||
- **And** charts use Recharts (already a dependency — verify) with the Memento color palette:
|
||||
- Line/area: `#ACB995` (sage) or `#D4A373` (ochre)
|
||||
- Grid: `border-border/40`
|
||||
- Labels: `text-[11px] text-muted-foreground`
|
||||
@@ -251,8 +251,8 @@ This story transforms the admin console into a **production-ready management das
|
||||
| `app/(admin)/admin/usage/page.tsx` | Usage analytics page |
|
||||
| `app/(admin)/admin/usage/usage-client.tsx` | Usage analytics client component (charts) |
|
||||
| `components/admin/user-detail-drawer.tsx` | Slide-in user detail panel |
|
||||
| `components/admin/charts/area-chart.tsx` | Recharts wrapper with Momento theme |
|
||||
| `components/admin/charts/bar-chart.tsx` | Recharts wrapper with Momento theme |
|
||||
| `components/admin/charts/area-chart.tsx` | Recharts wrapper with Memento theme |
|
||||
| `components/admin/charts/bar-chart.tsx` | Recharts wrapper with Memento theme |
|
||||
| `app/actions/admin-subscriptions.ts` | Server actions: getSubscriptions, cancelSubscription, getSubscriptionSummary |
|
||||
| `app/actions/admin-usage.ts` | Server actions: getUsageAggregate, getUsageByFeature, getTopUsers, getDailyUsage |
|
||||
| `app/actions/admin-feature-flags.ts` | Server actions: getFeatureFlags, createFeatureFlag, updateFeatureFlag, deleteFeatureFlag |
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
### Problème actuel
|
||||
|
||||
Momento stocke **un seul vecteur par note** (`NoteEmbedding` dans `prisma/schema.prisma:366-374`). L'embedding est généré par `EmbeddingService.generateNoteEmbedding()` (`lib/ai/services/embedding.service.ts:43-63`) qui :
|
||||
Memento stocke **un seul vecteur par note** (`NoteEmbedding` dans `prisma/schema.prisma:366-374`). L'embedding est généré par `EmbeddingService.generateNoteEmbedding()` (`lib/ai/services/embedding.service.ts:43-63`) qui :
|
||||
|
||||
1. Concatène titre + corps en plain text
|
||||
2. Découpe en chunks de 6000 chars (`splitPlainTextForEmbeddingChunks`)
|
||||
@@ -39,9 +39,9 @@ Inspiré d'AppFlowy (`flowy-ai/src/embeddings/document_indexer.rs`, `scheduler.r
|
||||
4. **Rechercher au niveau fragment** avec agrégation par note → snippets précis en résultat
|
||||
5. **Garder `NoteEmbedding` existant** pour rétro-compat (recherche globale, clustering)
|
||||
|
||||
### Comparaison AppFlowy → Momento
|
||||
### Comparaison AppFlowy → Memento
|
||||
|
||||
| Aspect | AppFlowy (Rust) | Momento (TypeScript) |
|
||||
| Aspect | AppFlowy (Rust) | Memento (TypeScript) |
|
||||
|--------|-----------------|---------------------|
|
||||
| Chunking | `text_splitter` crate, 1000 chars / 200 overlap | `lib/text/note-chunking.ts`, même valeurs |
|
||||
| Hash | `xxhash64` (Rust) | `crypto.createHash('sha256')` (Node natif) |
|
||||
@@ -79,7 +79,7 @@ model NoteEmbeddingChunk {
|
||||
|
||||
### Étapes de migration
|
||||
|
||||
1. **Dump DB obligatoire** : `bash /home/devparsa/dev/Momento/dump-db.sh` — vérifier ≥1Mo — « OUI » explicite utilisateur
|
||||
1. **Dump DB obligatoire** : `bash /home/devparsa/dev/Memento/dump-db.sh` — vérifier ≥1Mo — « OUI » explicite utilisateur
|
||||
2. `npx prisma migrate dev --name add_note_embedding_chunks`
|
||||
3. Créer l'index HNSW sur la colonne `embedding` :
|
||||
```sql
|
||||
@@ -180,7 +180,7 @@ import PQueue from 'p-queue'
|
||||
const chunkEmbeddingQueue = new PQueue({ concurrency: 4 })
|
||||
```
|
||||
|
||||
> Si Momento passe multi-process (PM2 cluster), migrer vers Bull. Pour l'instant, single-process Next.js → `p-queue` suffit.
|
||||
> Si Memento passe multi-process (PM2 cluster), migrer vers Bull. Pour l'instant, single-process Next.js → `p-queue` suffit.
|
||||
|
||||
---
|
||||
|
||||
@@ -756,6 +756,6 @@ class SemanticSearchService {
|
||||
## Notes
|
||||
|
||||
- **Conservation de `NoteEmbedding`** : la table existante reste le source of truth pour le clustering (`clustering.service.ts`) et la recherche globale. Les chunks sont une **couche additive** qui améliore la précision, pas un remplacement.
|
||||
- **AppFlowy utilise `nomic-embed-text` (768 dims, local via Ollama)**. Momento utilise `text-embedding-3-small` (1536 dims, OpenAI). La dimension est différente — le schéma `NoteEmbeddingChunk` doit spécifier `vector(1536)`.
|
||||
- **AppFlowy utilise `nomic-embed-text` (768 dims, local via Ollama)**. Memento utilise `text-embedding-3-small` (1536 dims, OpenAI). La dimension est différente — le schéma `NoteEmbeddingChunk` doit spécifier `vector(1536)`.
|
||||
- **Performance pgvector** : l'index HNSW est crucial pour les requêtes fragment-level. Sans index, un scan séquentiel sur des milliers de fragments serait prohibitif. L'index doit être créé dans la migration SQL brute.
|
||||
- **Persan / RTL** : le chunking par paragraphes fonctionne indépendamment de la langue. Le split par fin de phrase (`؟ !。`) couvre les scripts RTL et CJK. Vérifier que les embeddings `text-embedding-3-small` gèrent bien le persan (déjà validé pour la recherche existante).
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
|
||||
## 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).
|
||||
L'éditeur de notes actuel de Memento est construit sur [rich-text-editor.tsx](file:///home/devparsa/dev/Memento/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.
|
||||
3. **Bloc Database Inline :** Porter le composant de base de données relationnelle du prototype [ModernBlockNoteEditor.tsx](file:///home/devparsa/dev/Memento/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.
|
||||
|
||||
---
|
||||
|
||||
@@ -27,7 +27,7 @@ Pour offrir une expérience de saisie supérieure à celle de Notion (plus perfo
|
||||
**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
|
||||
* **Étant donné** que j'ai une note ouverte dans l'éditeur de Memento
|
||||
* **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)
|
||||
@@ -64,7 +64,7 @@ Pour offrir une expérience de saisie supérieure à celle de Notion (plus perfo
|
||||
* **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.
|
||||
* **Alors** le bloc ProseMirror est remplacé par un nœud de type `liveBlock` (provenant de notre [LiveBlockExtension](file:///home/devparsa/dev/Memento/memento-note/components/tiptap-live-block-extension.tsx#L159)) synchronisant le contenu en temps réel.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
|
||||
## Contexte
|
||||
|
||||
Momento dispose d'un moteur IA, d'un éditeur riche, de carnets, et d'un système de quotas. Mais aucun utilisateur nouveau n'est guidé vers l'expérience "Aha!" décrite dans le GTM :
|
||||
Memento dispose d'un moteur IA, d'un éditeur riche, de carnets, et d'un système de quotas. Mais aucun utilisateur nouveau n'est guidé vers l'expérience "Aha!" décrite dans le GTM :
|
||||
|
||||
> *"Tapez une question. Retrouvez une note que vous aviez oubliée."*
|
||||
|
||||
Sans onboarding, le taux d'activation sera faible même avec un produit excellent. Un utilisateur qui arrive sur `/home` sans notes ne comprend pas ce que Momento fait. Le wizard doit :
|
||||
Sans onboarding, le taux d'activation sera faible même avec un produit excellent. Un utilisateur qui arrive sur `/home` sans notes ne comprend pas ce que Memento fait. Le wizard doit :
|
||||
|
||||
1. Créer des **données de démo** (5 notes exemple dans sa langue) si l'utilisateur arrive avec un carnet vide
|
||||
2. Guider vers la **Recherche Sémantique** en 2 clics (l'effet "Aha!")
|
||||
@@ -60,12 +60,12 @@ model User {
|
||||
### US-ONBOARDING-2 : Wizard 3 étapes
|
||||
|
||||
**En tant que** nouvel utilisateur,
|
||||
**Je veux** un guide en 3 étapes courtes qui me montre la valeur de Momento,
|
||||
**Je veux** un guide en 3 étapes courtes qui me montre la valeur de Memento,
|
||||
**Afin de** comprendre pourquoi je devrais utiliser ce produit plutôt qu'un autre.
|
||||
|
||||
#### Étape 1 — "Bienvenue" (10 secondes)
|
||||
- Titre : *"Votre mémoire augmentée par l'IA"*
|
||||
- Sous-titre : *"Momento se souvient de ce que vous oubliez."*
|
||||
- Sous-titre : *"Memento se souvient de ce que vous oubliez."*
|
||||
- CTA : `"Commencer →"` + lien `"Passer l'intro"`
|
||||
|
||||
#### Étape 2 — "Vos notes" (30 secondes)
|
||||
@@ -85,7 +85,7 @@ model User {
|
||||
- FA : *"یادداشتهای بهرهوری"* (RTL)
|
||||
- L'utilisateur clique sur Rechercher → les résultats apparaissent
|
||||
- Afficher badge : `"✨ 1 recherche utilisée sur 30 (Starter Pack)"`
|
||||
- CTA final : `"Je comprends — Explorer Momento"`
|
||||
- CTA final : `"Je comprends — Explorer Memento"`
|
||||
|
||||
#### Critères d'acceptation généraux :
|
||||
- Wizard rendu en overlay (`position: fixed`, z-index élevé) avec fond semi-transparent
|
||||
@@ -177,7 +177,7 @@ model User {
|
||||
{
|
||||
"onboarding": {
|
||||
"welcome_title": "Your AI-augmented memory",
|
||||
"welcome_subtitle": "Momento remembers what you forget.",
|
||||
"welcome_subtitle": "Memento remembers what you forget.",
|
||||
"welcome_cta": "Get started",
|
||||
"skip": "Skip intro",
|
||||
"step_notes_title": "Your notes",
|
||||
@@ -189,7 +189,7 @@ model User {
|
||||
"step_aha_title": "Find what you forgot",
|
||||
"step_aha_subtitle": "Type a question. Find a note you forgot.",
|
||||
"step_aha_placeholder": "notes about productivity...",
|
||||
"step_aha_cta": "Explore Momento",
|
||||
"step_aha_cta": "Explore Memento",
|
||||
"progress": "{current} of {total}"
|
||||
},
|
||||
"starterPack": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Momento — Guide Stripe : Configuration, Architecture et Utilisation
|
||||
# Memento — Guide Stripe : Configuration, Architecture et Utilisation
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Momento utilise **Stripe** pour la gestion des abonnements payants (Pro, Business, Enterprise). Le système repose sur :
|
||||
Memento utilise **Stripe** pour la gestion des abonnements payants (Pro, Business, Enterprise). Le système repose sur :
|
||||
|
||||
- **Stripe Embedded Checkout** (modal dans l'app, sans redirection)
|
||||
- **Webhooks** pour synchroniser l'état des abonnements en temps réel
|
||||
@@ -86,11 +86,11 @@ Momento utilise **Stripe** pour la gestion des abonnements payants (Pro, Busines
|
||||
|
||||
Dans le **Stripe Dashboard** → **Produits** :
|
||||
|
||||
#### Produit 1 : Momento Pro
|
||||
#### Produit 1 : Memento Pro
|
||||
|
||||
| Champ | Valeur |
|
||||
|-------|--------|
|
||||
| Nom | Momento Pro |
|
||||
| Nom | Memento Pro |
|
||||
| Description | Pour les consultants et créateurs exigeants |
|
||||
|
||||
Créer **2 prix** :
|
||||
@@ -100,11 +100,11 @@ Créer **2 prix** :
|
||||
| Pro Mensuel | 9,90 EUR | Tous les mois |
|
||||
| Pro Annuel | 99 EUR | Tous les ans |
|
||||
|
||||
#### Produit 2 : Momento Business
|
||||
#### Produit 2 : Memento Business
|
||||
|
||||
| Champ | Valeur |
|
||||
|-------|--------|
|
||||
| Nom | Momento Business |
|
||||
| Nom | Memento Business |
|
||||
| Description | Pour les équipes et chefs de produit |
|
||||
|
||||
Créer **2 prix** :
|
||||
@@ -244,7 +244,7 @@ Utilisateur Frontend Backend
|
||||
|
||||
### 3.2 Webhook — Cycle de vie des abonnements
|
||||
|
||||
| Événement Stripe | Action Momento | Statut Prisma |
|
||||
| Événement Stripe | Action Memento | Statut Prisma |
|
||||
|------------------|---------------|---------------|
|
||||
| `checkout.session.completed` | Upsert subscription avec tier/periode | `ACTIVE` |
|
||||
| `customer.subscription.created` | Upsert (nouvelle souscription) | Selon Stripe |
|
||||
@@ -454,7 +454,7 @@ Devise : EUR (configurable dans Stripe Dashboard pour multi-devises).
|
||||
|
||||
## 11. Bons de réduction & Codes de promotion (Coupons & Promo Codes)
|
||||
|
||||
Momento intègre le support natif et sécurisé de Stripe pour les codes promotionnels lors du paiement en Embedded Checkout via l'attribut `allow_promotion_codes: true` dans `/api/billing/create-checkout/route.ts`.
|
||||
Memento intègre le support natif et sécurisé de Stripe pour les codes promotionnels lors du paiement en Embedded Checkout via l'attribut `allow_promotion_codes: true` dans `/api/billing/create-checkout/route.ts`.
|
||||
|
||||
### 11.1 Concepts Clés : Bon de réduction (Coupon) vs Code de promotion (Promo Code)
|
||||
Dans Stripe, la gestion des remises se fait en deux niveaux :
|
||||
@@ -477,7 +477,7 @@ Dans Stripe, la gestion des remises se fait en deux niveaux :
|
||||
- Nombre d'utilisations max (ex: limité aux 100 premiers utilisateurs).
|
||||
- Date limite de validité (ex: valable uniquement jusqu'au 31 décembre).
|
||||
- Limiter aux clients n'ayant jamais payé.
|
||||
- Restriction à des plans spécifiques (ex: restreindre ce code promo uniquement au produit `Momento Pro`).
|
||||
- Restriction à des plans spécifiques (ex: restreindre ce code promo uniquement au produit `Memento Pro`).
|
||||
5. Cliquez sur **Créer le bon de réduction**.
|
||||
|
||||
### 11.3 Comment désactiver ou supprimer un Code Promo
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# User Stories — Momento Next Phase
|
||||
# User Stories — Memento Next Phase
|
||||
|
||||
> Basé sur l'analyse du prototype `architectural-grid/` et du code production `memento-note/`.
|
||||
> Dernière mise à jour : 2026-05-29 (Epic 6 Croissance & Activation ajouté — analyse stratégique Mary/BMad)
|
||||
> Dernière mise à jour : 2026-06-28 (Epic 6 terminé — sync avec sprint-status.yaml)
|
||||
|
||||
---
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
| **US-EDITOR-PERF** | Performance de frappe TipTap (quick wins) | ✅ **LIVRÉ** | `rich-text-editor.tsx` (useEditorState), `note-editor-context.tsx` (debounced setContent) |
|
||||
| **US-EDITOR-UX** | Micro-interactions saisie (slash menu, sélection multi-blocs, paste étendu, placeholders) | ✅ **LIVRÉ** | Sélection globale, redesign Slash Menu (favoris/preview), placeholders contextuels, smart paste étendu, Turn Into & Undo/Redo |
|
||||
| **US-EDITOR-MOBILE** | Expérience tactile & toolbar mobile adaptée | ✅ **LIVRÉ** | Toolbar fixe premium 44px, Bottom Sheet tactile (actions de bloc + IA), sélection facilitée de bloc |
|
||||
| **US-EDITOR-MARKDOWN** | Rendu WYSIWYG Markdown fidèle (round-trip byte-for-byte) | ⏳ **À FAIRE** | Brief : `docs/brief-markdown-roundtrip.md` |
|
||||
| **US-ONBOARDING** | Wizard Activation — Effet "Aha!" Recherche Sémantique | 🆕 **À FAIRE** | Story : `docs/story-onboarding-activation.md` |
|
||||
| **US-BRAINSTORM-FINALIZE** | Brainstorm Canvas D3 — Finalisation (export PPTX, gaps UX) | 🆕 **À FAIRE** | ~75% code existant (`brainstorm-page.tsx`, 14 routes API) |
|
||||
| **US-CHAT-PDF** | Chat with PDF — RAG documentaire | 🆕 **À FAIRE** | — |
|
||||
| **US-EDITOR-MARKDOWN** | Rendu WYSIWYG Markdown fidèle (round-trip byte-for-byte) | ✅ **LIVRÉ** | Brief : `docs/brief-markdown-roundtrip.md` |
|
||||
| **US-ONBOARDING** | Wizard Activation — Effet "Aha!" Recherche Sémantique | ✅ **LIVRÉ** | Story : `docs/story-onboarding-activation.md` |
|
||||
| **US-BRAINSTORM-FINALIZE** | Brainstorm Canvas D3 — Finalisation (export PPTX, gaps UX) | ✅ **LIVRÉ** | `brainstorm-page.tsx`, 14 routes API |
|
||||
| **US-CHAT-PDF** | Chat with PDF — RAG documentaire | ✅ **LIVRÉ** | `document-qa-overlay.tsx`, `document-ingestion`, `document-search` tool |
|
||||
| **US-PPTX-EXPORT** | Export PPTX + Watermark | ✅ **LIVRÉ** | `lib/brainstorm/export-pptx.ts`, `lib/ai/tools/pptx.tool.ts` |
|
||||
| **US-PUBLISH-IA** | Publication IA (templates magazine/brief/essay + rewrite) | ✅ **LIVRÉ** | `lib/publish/`, `publish-enhance.service.ts`, 4 templates CSS, quota `publish_enhance` |
|
||||
|
||||
---
|
||||
|
||||
@@ -119,7 +121,7 @@ Le prototype `SearchModal.tsx` est une refonte complète avec dual-panel, regex,
|
||||
**Contexte :**
|
||||
Le prototype contient `ClipperSimulator.tsx` (618 lignes) qui simule le clipping avec données mock. Il n'existe rien d'équivalent dans `memento-note`. La feature doit être réalisée en deux parties : une **extension Chrome/Firefox** et un **modal de réception** côté app.
|
||||
|
||||
**En tant qu'utilisateur**, je veux capturer n'importe quelle page web depuis mon navigateur et l'enregistrer dans Momento avec résumé IA, tags suggérés et choix du carnet — sans quitter le navigateur.
|
||||
**En tant qu'utilisateur**, je veux capturer n'importe quelle page web depuis mon navigateur et l'enregistrer dans Memento avec résumé IA, tags suggérés et choix du carnet — sans quitter le navigateur.
|
||||
|
||||
**Critères d'acceptation :**
|
||||
|
||||
@@ -129,7 +131,7 @@ Le prototype contient `ClipperSimulator.tsx` (618 lignes) qui simule le clipping
|
||||
- Bouton "Analyser avec IA" → appel `POST /api/clip/analyze` (URL + HTML content) → retourne `{ title, summary, tags[], readingTime }`
|
||||
- Champ de sélection du carnet (dropdown hiérarchique, dernier carnet mémorisé)
|
||||
- Aperçu du contenu clipé (150px scrollable)
|
||||
- Bouton "Sauvegarder dans Momento" → appel `POST /api/clip/save` avec `{ url, title, content, summary, tags, notebookId }`
|
||||
- Bouton "Sauvegarder dans Memento" → appel `POST /api/clip/save` avec `{ url, title, content, summary, tags, notebookId }`
|
||||
- Feedback visuel : spinner → "Sauvegardé ✓" avec lien direct vers la note
|
||||
|
||||
### Route API `app/api/clip/analyze/route.ts` (nouvelle)
|
||||
@@ -653,7 +655,7 @@ L'éditeur actuel est un document linéaire classique. Pour rivaliser avec Notio
|
||||
> **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.
|
||||
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). Memento 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.
|
||||
|
||||
@@ -711,7 +713,7 @@ const { isBold, isItalic, isHeading } = useEditorState({
|
||||
> **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.
|
||||
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 Memento.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -802,7 +804,7 @@ L'éditeur fonctionne sur mobile mais l'expérience est dégradée : la bubble m
|
||||
> **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.
|
||||
Memento 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.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ inputDocuments:
|
||||
- memento-note/docs/saas-deployment-prep.md
|
||||
---
|
||||
|
||||
# UX Design Specification Momento
|
||||
# UX Design Specification Memento
|
||||
|
||||
**Author:** devparsa
|
||||
**Date:** 2026-05-14
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function LoginScreen() {
|
||||
|
||||
{/* Logo */}
|
||||
<View style={s.logoBlock}>
|
||||
<Text style={s.logo}>Momento</Text>
|
||||
<Text style={s.logo}>Memento</Text>
|
||||
<Text style={s.tagline}>Votre espace de connaissance</Text>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* AISheet — bottom sheet IA avec modes d'amélioration + résultat
|
||||
* Remplace les Alert.alert natifs par une UI propre au design Momento
|
||||
* Remplace les Alert.alert natifs par une UI propre au design Memento
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* BottomSheet — modal bas d'écran respectant le design Momento
|
||||
* BottomSheet — modal bas d'écran respectant le design Memento
|
||||
* Usage:
|
||||
* <BottomSheet visible={v} onClose={() => setV(false)} title="Titre">
|
||||
* ...children
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function AuthLayout({
|
||||
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] 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">Momento</span>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</Link>
|
||||
|
||||
<div className="w-8" />
|
||||
@@ -39,7 +39,7 @@ export default function AuthLayout({
|
||||
<div className="w-full max-w-md">
|
||||
{children}
|
||||
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
|
||||
© 2025 Momento Labs
|
||||
© 2025 Memento Labs
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -12,8 +12,8 @@ export function AISettingsHeader() {
|
||||
steps={[
|
||||
{ text: 'Choisissez un fournisseur IA parmi 8 disponibles : OpenAI, Anthropic, Google, Mistral, Groq, DeepSeek, Perplexity, ou Ollama (local).' },
|
||||
{ text: 'Sélectionnez le modèle — chaque fournisseur propose plusieurs modèles avec des capacités différentes (rapidité, coût, contexte).' },
|
||||
{ text: 'Mode BYOK (Bring Your Own Key) : entrez votre propre clé API pour utiliser votre quota directement — sans passer par les quotas Momento.' },
|
||||
{ text: 'Les crédits Momento (quota mensuel) sont utilisés quand vous n\'avez pas de clé personnelle. Le plan Free inclut 10 crédits/mois.' },
|
||||
{ text: 'Mode BYOK (Bring Your Own Key) : entrez votre propre clé API pour utiliser votre quota directement — sans passer par les quotas Memento.' },
|
||||
{ text: 'Les crédits Memento (quota mensuel) sont utilisés quand vous n\'avez pas de clé personnelle. Le plan Free inclut 10 crédits/mois.' },
|
||||
{ icon: '💡', text: 'Conseil : Ollama permet de faire tourner un LLM entièrement en local sur votre machine — 100% privé, zéro coût.' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function DataSettingsPage() {
|
||||
steps={[
|
||||
{ text: 'Export ZIP : télécharge toutes vos notes en fichiers Markdown + images + métadonnées. Format universel, lisible partout.' },
|
||||
{ text: 'Export JSON : version structurée de vos données — utile pour migrer ou sauvegarder programmatiquement.' },
|
||||
{ text: 'Import Markdown : importez un fichier .md existant directement dans Momento en tant que nouvelle note.' },
|
||||
{ text: 'Import Markdown : importez un fichier .md existant directement dans Memento en tant que nouvelle note.' },
|
||||
{ text: 'Ré-indexer : si la recherche sémantique ne trouve pas certaines notes, relancez l\'indexation pour recalculer les embeddings.' },
|
||||
{ icon: '⚠️', text: 'Supprimer le compte efface définitivement toutes vos données. Pensez à exporter d\'abord.' },
|
||||
]}
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function IntegrationsPage() {
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-serif font-medium text-ink italic tracking-tight">Intégrations</h2>
|
||||
<p className="text-sm text-concrete mt-1">Connectez des services externes à Momento.</p>
|
||||
<p className="text-sm text-concrete mt-1">Connectez des services externes à Memento.</p>
|
||||
</div>
|
||||
|
||||
{/* ── Google Calendar ────────────────────────────────────────────── */}
|
||||
@@ -166,7 +166,7 @@ export default function IntegrationsPage() {
|
||||
{ text: 'Cliquez "Connecter Google Calendar" — vous serez redirigé vers Google pour autoriser l\'accès.' },
|
||||
{ text: 'Une fois connecté, revenez ici et cliquez "Événements aujourd\'hui" pour voir votre agenda.' },
|
||||
{ text: 'Sur chaque événement, cliquez "+ Note" pour créer automatiquement une note de réunion avec template (Ordre du jour / Notes / Actions).' },
|
||||
{ text: 'La note s\'ouvre directement dans Momento — ajoutez vos notes en temps réel pendant la réunion.' },
|
||||
{ text: 'La note s\'ouvre directement dans Memento — ajoutez vos notes en temps réel pendant la réunion.' },
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -19,11 +19,11 @@ export default async function McpSettingsPage() {
|
||||
title="Qu'est-ce que MCP (Model Context Protocol) ?"
|
||||
defaultOpen={true}
|
||||
steps={[
|
||||
{ text: 'MCP est un protocole qui permet aux agents IA de Momento de se connecter à des outils externes (bases de données, APIs, fichiers, etc.).' },
|
||||
{ text: 'Momento expose un serveur MCP avec 22 outils — vos agents peuvent lire/créer des notes, chercher dans votre base, gérer les carnets, etc.' },
|
||||
{ text: 'MCP est un protocole qui permet aux agents IA de Memento de se connecter à des outils externes (bases de données, APIs, fichiers, etc.).' },
|
||||
{ text: 'Memento expose un serveur MCP avec 22 outils — vos agents peuvent lire/créer des notes, chercher dans votre base, gérer les carnets, etc.' },
|
||||
{ text: 'Créez une clé API ici, puis configurez-la dans votre client MCP (Claude Desktop, Cursor, Continue.dev…) avec l\'URL du serveur.' },
|
||||
{ text: 'Format de configuration : URL du serveur MCP + votre clé dans le header Authorization.', link: { label: 'Documentation MCP', href: 'https://modelcontextprotocol.io/docs' } },
|
||||
{ icon: '⚡', text: 'Cas d\'usage : demandez à Claude Desktop d\'écrire une note dans Momento, de chercher dans vos carnets, ou de créer un agent.' },
|
||||
{ icon: '⚡', text: 'Cas d\'usage : demandez à Claude Desktop d\'écrire une note dans Memento, de chercher dans vos carnets, ou de créer un agent.' },
|
||||
]}
|
||||
/>
|
||||
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
||||
|
||||
403
memento-note/app/(public)/c/[slug]/page.tsx
Normal file
403
memento-note/app/(public)/c/[slug]/page.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { Flag, Sparkles, BookOpen, Clock } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
|
||||
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
|
||||
|
||||
export const revalidate = 60
|
||||
|
||||
async function getNotebookSite(slug: string) {
|
||||
const site = await prisma.notebookSite.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
notebook: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
icon: true,
|
||||
user: { select: { name: true, image: true } },
|
||||
notes: {
|
||||
select: { id: true, title: true, content: true, order: true, updatedAt: true },
|
||||
where: { trashedAt: null, isArchived: false },
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!site || !site.isPublic) return null
|
||||
return site
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const site = await getNotebookSite(slug)
|
||||
if (!site) return { title: 'Site introuvable — Memento' }
|
||||
return {
|
||||
title: `${site.notebook.name} — Memento`,
|
||||
description: site.description || `Carnet publié sur Memento`,
|
||||
openGraph: { title: site.notebook.name, description: site.description || '' },
|
||||
}
|
||||
}
|
||||
|
||||
function wordCount(html: string) {
|
||||
return (html || '').replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length
|
||||
}
|
||||
function readingTime(html: string) {
|
||||
return Math.max(1, Math.ceil(wordCount(html) / 200))
|
||||
}
|
||||
function preview(html: string, len = 140) {
|
||||
return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, len)
|
||||
}
|
||||
|
||||
export default async function NotebookSitePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const site = await getNotebookSite(slug)
|
||||
if (!site) notFound()
|
||||
|
||||
let selectedIds: string[] = []
|
||||
try { selectedIds = JSON.parse(site.selectedNoteIds) } catch {}
|
||||
|
||||
const allNotes = site.notebook.notes
|
||||
const orderedNotes = selectedIds.length > 0
|
||||
? selectedIds.map(id => allNotes.find(n => n.id === id)).filter(Boolean) as typeof allNotes
|
||||
: allNotes
|
||||
|
||||
const notebook = site.notebook
|
||||
const totalWc = orderedNotes.reduce((s, n) => s + wordCount(n.content || ''), 0)
|
||||
const totalRt = Math.max(1, Math.ceil(totalWc / 200))
|
||||
|
||||
return (
|
||||
<>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Source+Serif+4:opsz,wght@8..60,300..600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
|
||||
{/* ── NAV ── */}
|
||||
<nav className="cs-nav">
|
||||
<a href="/" className="cs-nav-brand">Memento</a>
|
||||
<div className="cs-nav-right">
|
||||
<span className="cs-nav-badge"><Sparkles size={10} /> Site publié</span>
|
||||
<a href={`/c/${slug}/report`} className="cs-nav-report"><Flag size={11} /> Signaler</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── HERO ── */}
|
||||
<header className="cs-hero">
|
||||
<div className="cs-hero-inner">
|
||||
{notebook.icon && <div className="cs-hero-icon">{notebook.icon}</div>}
|
||||
<div className="cs-hero-label">CARNET · {orderedNotes.length} NOTES · {totalRt} MIN</div>
|
||||
<h1 className="cs-hero-title">{notebook.name}</h1>
|
||||
{site.description && <p className="cs-hero-desc">{site.description}</p>}
|
||||
{notebook.user?.name && (
|
||||
<div className="cs-hero-author">
|
||||
{notebook.user.image && <img src={notebook.user.image} alt="" className="cs-hero-avatar" />}
|
||||
<span>par <strong>{notebook.user.name}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── TABLE DES MATIÈRES ── */}
|
||||
<section className="cs-toc-section">
|
||||
<div className="cs-toc-inner">
|
||||
<p className="cs-toc-label">TABLE DES MATIÈRES</p>
|
||||
<div className="cs-toc-grid">
|
||||
{orderedNotes.map((note, i) => (
|
||||
<a key={note.id} href={`#note-${note.id}`} className="cs-toc-card">
|
||||
<span className="cs-toc-num">{String(i + 1).padStart(2, '0')}</span>
|
||||
<div>
|
||||
<div className="cs-toc-card-title">{note.title || 'Sans titre'}</div>
|
||||
<div className="cs-toc-card-preview">{preview(note.content || '')}</div>
|
||||
<div className="cs-toc-card-rt"><Clock size={10} /> {readingTime(note.content || '')} min</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── NOTES ── */}
|
||||
<main className="cs-main">
|
||||
<aside className="cs-sidebar">
|
||||
<p className="cs-sidebar-label">NAVIGATION</p>
|
||||
{orderedNotes.map((note, i) => (
|
||||
<a key={note.id} href={`#note-${note.id}`} className="cs-sidebar-link">
|
||||
<span className="cs-sidebar-num">{i + 1}</span>
|
||||
<span className="cs-sidebar-title">{note.title || 'Sans titre'}</span>
|
||||
</a>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div className="cs-articles">
|
||||
{orderedNotes.map((note, i) => {
|
||||
const bodyHtml = processNoteHtmlForPublish(note.content || '')
|
||||
const rt = readingTime(note.content || '')
|
||||
return (
|
||||
<article key={note.id} id={`note-${note.id}`} className="cs-article">
|
||||
<div className="cs-article-meta">
|
||||
<span className="cs-article-num">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="cs-article-rt"><Clock size={10} /> {rt} min de lecture</span>
|
||||
</div>
|
||||
<h2 className="cs-article-title">{note.title || 'Sans titre'}</h2>
|
||||
<div
|
||||
className="cs-article-body pub-article pub-rewrite-body"
|
||||
dir="auto"
|
||||
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||
/>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* ── FOOTER ── */}
|
||||
<footer className="cs-footer">
|
||||
<div className="cs-footer-inner">
|
||||
<span>Publié avec <a href="/" className="cs-footer-brand">Memento</a></span>
|
||||
<a href={`/c/${slug}/report`} className="cs-footer-report"><Flag size={11} /> Signaler ce contenu</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>{`
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { background: #fff; color: #111; font-family: 'Inter', system-ui, sans-serif; }
|
||||
|
||||
/* ── NAV ── */
|
||||
.cs-nav {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
background: #111; color: #fff;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 32px; height: 52px;
|
||||
}
|
||||
.cs-nav-brand {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 17px; font-weight: 900; letter-spacing: .04em;
|
||||
color: #fff; text-decoration: none;
|
||||
}
|
||||
.cs-nav-right { display: flex; align-items: center; gap: 16px; }
|
||||
.cs-nav-badge {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: .12em;
|
||||
text-transform: uppercase; color: #f5c96a;
|
||||
}
|
||||
.cs-nav-report {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 11px; color: rgba(255,255,255,.4); text-decoration: none;
|
||||
}
|
||||
.cs-nav-report:hover { color: rgba(255,255,255,.7); }
|
||||
|
||||
/* ── HERO ── */
|
||||
.cs-hero {
|
||||
background: #111; color: #fff;
|
||||
padding: 72px 32px 80px; text-align: center;
|
||||
}
|
||||
.cs-hero-inner { max-width: 780px; margin: 0 auto; }
|
||||
.cs-hero-icon { font-size: 48px; margin-bottom: 20px; line-height: 1; }
|
||||
.cs-hero-label {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .22em;
|
||||
text-transform: uppercase; color: #f5c96a; margin-bottom: 24px;
|
||||
}
|
||||
.cs-hero-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: clamp(36px, 6vw, 66px);
|
||||
font-weight: 900; line-height: 1.08; letter-spacing: -.02em;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.cs-hero-desc {
|
||||
font-size: 18px; line-height: 1.7; color: rgba(255,255,255,.65);
|
||||
max-width: 600px; margin: 0 auto 28px;
|
||||
}
|
||||
.cs-hero-author {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
font-size: 13px; color: rgba(255,255,255,.5);
|
||||
}
|
||||
.cs-hero-avatar {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,.2); object-fit: cover;
|
||||
}
|
||||
|
||||
/* ── TABLE DES MATIÈRES ── */
|
||||
.cs-toc-section { background: #f8f8f6; border-bottom: 1px solid #e8e8e4; padding: 56px 32px; }
|
||||
.cs-toc-inner { max-width: 1040px; margin: 0 auto; }
|
||||
.cs-toc-label {
|
||||
font-size: 10px; font-weight: 700; letter-spacing: .2em;
|
||||
color: #aaa; text-transform: uppercase; margin-bottom: 24px;
|
||||
}
|
||||
.cs-toc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.cs-toc-card {
|
||||
display: flex; gap: 16px; align-items: flex-start;
|
||||
background: #fff; border: 1px solid #e8e8e4;
|
||||
border-radius: 12px; padding: 20px 22px;
|
||||
text-decoration: none; color: inherit;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.cs-toc-card:hover {
|
||||
border-color: #111;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.07);
|
||||
}
|
||||
.cs-toc-num {
|
||||
font-size: 11px; font-weight: 700; color: #f5c96a;
|
||||
background: #111; border-radius: 6px;
|
||||
padding: 4px 7px; line-height: 1; flex-shrink: 0; margin-top: 2px;
|
||||
}
|
||||
.cs-toc-card-title { font-size: 14px; font-weight: 700; color: #111; line-height: 1.35; margin-bottom: 6px; }
|
||||
.cs-toc-card-preview { font-size: 12px; color: #777; line-height: 1.5; margin-bottom: 8px; }
|
||||
.cs-toc-card-rt {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 10px; color: #bbb; font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── LAYOUT PRINCIPAL ── */
|
||||
.cs-main {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
display: grid; grid-template-columns: 260px 1fr;
|
||||
gap: 0; min-height: 60vh;
|
||||
}
|
||||
@media (max-width: 820px) { .cs-main { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── SIDEBAR ── */
|
||||
.cs-sidebar {
|
||||
padding: 48px 24px 48px 32px;
|
||||
border-right: 1px solid #f0f0ee;
|
||||
position: sticky; top: 52px;
|
||||
height: calc(100vh - 52px); overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 820px) { .cs-sidebar { display: none; } }
|
||||
.cs-sidebar-label {
|
||||
font-size: 9px; font-weight: 700; letter-spacing: .2em;
|
||||
color: #bbb; text-transform: uppercase; margin-bottom: 16px;
|
||||
padding-bottom: 12px; border-bottom: 1px solid #f0f0ee;
|
||||
}
|
||||
.cs-sidebar-link {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 8px; margin-bottom: 2px;
|
||||
text-decoration: none; color: #555; font-size: 12.5px; line-height: 1.45;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.cs-sidebar-link:hover { background: #f5f5f3; color: #111; }
|
||||
.cs-sidebar-num {
|
||||
flex-shrink: 0; font-size: 10px; font-weight: 700;
|
||||
color: #ccc; width: 18px; text-align: right; padding-top: 1px;
|
||||
}
|
||||
.cs-sidebar-title { font-weight: 500; }
|
||||
|
||||
/* ── ARTICLES ── */
|
||||
.cs-articles { padding: 64px 60px; }
|
||||
@media (max-width: 1024px) { .cs-articles { padding: 48px 32px; } }
|
||||
@media (max-width: 640px) { .cs-articles { padding: 32px 20px; } }
|
||||
|
||||
.cs-article { padding-top: 56px; margin-top: 56px; border-top: 1px solid #eeecea; }
|
||||
.cs-article:first-child { margin-top: 0; padding-top: 0; border-top: none; }
|
||||
|
||||
.cs-article-meta {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cs-article-num {
|
||||
font-size: 11px; font-weight: 700; color: #f5c96a;
|
||||
background: #111; border-radius: 6px; padding: 3px 8px; line-height: 1;
|
||||
}
|
||||
.cs-article-rt {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 11px; color: #bbb; font-weight: 500;
|
||||
}
|
||||
|
||||
.cs-article-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: clamp(24px, 3.5vw, 38px);
|
||||
font-weight: 900; line-height: 1.15; letter-spacing: -.01em;
|
||||
color: #111; margin-bottom: 32px;
|
||||
}
|
||||
|
||||
/* ── ARTICLE BODY ── */
|
||||
.cs-article-body {
|
||||
font-family: 'Source Serif 4', Georgia, serif;
|
||||
font-size: 18px; line-height: 1.85; color: #1a1a1a;
|
||||
max-width: 680px;
|
||||
}
|
||||
.cs-article-body h2 {
|
||||
font-family: 'Playfair Display', serif; font-size: 1.5em;
|
||||
font-weight: 700; margin-top: 2.5em; margin-bottom: .6em; color: #111;
|
||||
}
|
||||
.cs-article-body h3 {
|
||||
font-size: 1.15em; font-weight: 700;
|
||||
margin-top: 2em; margin-bottom: .5em; color: #111;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.cs-article-body p { margin: 1em 0; }
|
||||
.cs-article-body blockquote {
|
||||
border-left: 4px solid #f5c96a; padding-left: 1.2em;
|
||||
margin: 1.5em 0; color: #444; font-style: italic;
|
||||
}
|
||||
.cs-article-body ul, .cs-article-body ol { padding-left: 1.5em; margin: 1em 0; }
|
||||
.cs-article-body li { margin: .4em 0; }
|
||||
.cs-article-body pre {
|
||||
background: #111; color: #e8e3d5; padding: 22px 24px;
|
||||
border-radius: 10px; overflow-x: auto; font-size: 13px; margin: 1.5em 0;
|
||||
font-family: 'SF Mono', Menlo, monospace;
|
||||
}
|
||||
.cs-article-body code {
|
||||
font-family: 'SF Mono', Menlo, monospace; font-size: .87em;
|
||||
background: #f3f2ef; padding: 1px 5px; border-radius: 3px; color: #1a1a1a;
|
||||
}
|
||||
.cs-article-body pre code { background: none; color: inherit; padding: 0; }
|
||||
.cs-article-body table { border-collapse: collapse; width: 100%; margin: 1.5em 0; }
|
||||
.cs-article-body th {
|
||||
background: #111; color: #fff; padding: 10px 14px;
|
||||
text-align: left; font-size: 13px; letter-spacing: .05em; font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.cs-article-body td { border-bottom: 1px solid #e8e8e4; padding: 10px 14px; }
|
||||
.cs-article-body a { color: #b8832a; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.cs-article-body .pub-figure { margin: 2.5em 0; text-align: center; }
|
||||
.cs-article-body .pub-figure-img { max-width: 100%; border-radius: 8px; box-shadow: 0 12px 40px rgba(0,0,0,.1); }
|
||||
.cs-article-body .pub-figure-caption { margin-top: .6em; font-size: .8em; color: #888; font-style: italic; }
|
||||
.cs-article-body .pub-gallery {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px; margin: 2em 0;
|
||||
}
|
||||
.cs-article-body .pub-gallery-img { width: 100%; height: 180px; object-fit: cover; border-radius: 8px; display: block; }
|
||||
|
||||
/* Variables CSS pour blocs structurés */
|
||||
.cs-article-body {
|
||||
--pub-accent: #9a7212;
|
||||
--pub-summary-color: #333;
|
||||
--pub-exercise-border: #e0d4c3;
|
||||
--pub-exercise-header-bg: #faf6ee;
|
||||
--pub-solution-bg: #faf6ee;
|
||||
--pub-definition-border: #e0d4c3;
|
||||
--pub-definition-bg: #fafaf8;
|
||||
--pub-toggle-border: #e5e5e5;
|
||||
--pub-toggle-header-bg: #f7f7f5;
|
||||
--pub-highlight-bg: #faf6ee;
|
||||
--pub-checklist-bg: rgba(0,0,0,.03);
|
||||
}
|
||||
.cs-article-body .pub-code { background: #0d0d0d; }
|
||||
.cs-article-body .pub-code code { background: #0d0d0d; color: #e8e3d5; }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
.cs-footer { background: #111; color: rgba(255,255,255,.4); padding: 32px; }
|
||||
.cs-footer-inner {
|
||||
max-width: 1040px; margin: 0 auto;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 13px; flex-wrap: wrap; gap: 12px;
|
||||
}
|
||||
.cs-footer-brand { color: #f5c96a; font-weight: 700; text-decoration: none; }
|
||||
.cs-footer-report {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-size: 11px; color: rgba(255,255,255,.3); text-decoration: none;
|
||||
}
|
||||
.cs-footer-report:hover { color: rgba(255,255,255,.6); }
|
||||
|
||||
${KATEX_PUBLISH_CSS}
|
||||
${REWRITE_SHARED_CSS}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,146 +1,589 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPublishedNote } from '@/app/actions/notes-publishing'
|
||||
import { Calendar, Clock, Flag } from 'lucide-react'
|
||||
import { Flag, Sparkles } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import katex from 'katex'
|
||||
import { sanitizeRichHtml } from '@/lib/sanitize-content'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { computePublishedSourceHash } from '@/lib/publish/template-render'
|
||||
import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
|
||||
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const note = await getPublishedNote(slug)
|
||||
if (!note) return { title: 'Note not found — Momento' }
|
||||
if (!note) return { title: 'Note introuvable — Memento' }
|
||||
return {
|
||||
title: `${note.title || 'Published note'} — Momento`,
|
||||
title: `${note.title || 'Note publiée'} — Memento`,
|
||||
description: note.content?.replace(/<[^>]+>/g, '').slice(0, 160),
|
||||
}
|
||||
}
|
||||
|
||||
export const revalidate = 60
|
||||
|
||||
function decodeHtml(text: string): string {
|
||||
const map: Record<string, string> = { '"': '"', '&': '&', '<': '<', '>': '>', ''': "'" }
|
||||
return text.replace(/&[a-z#0-9]+;/gi, m => map[m] || m)
|
||||
}
|
||||
|
||||
function processContent(html: string): string {
|
||||
let result = html
|
||||
|
||||
// KaTeX block math
|
||||
result = result.replace(/<div[^>]*data-type="math-equation"[^>]*data-latex="([^"]*)"[^>]*><\/div>/g, (_, latex) => {
|
||||
try { return `<div class="r-math-display">${katex.renderToString(decodeHtml(latex), { displayMode: true, throwOnError: false })}</div>` }
|
||||
catch { return `<div class="r-math-display">${latex}</div>` }
|
||||
})
|
||||
// KaTeX inline math
|
||||
result = result.replace(/<span[^>]*data-type="inline-math"[^>]*data-latex="([^"]*)"[^>]*>.*?<\/span>/g, (_, latex) => {
|
||||
try { return katex.renderToString(decodeHtml(latex), { displayMode: false, throwOnError: false }) }
|
||||
catch { return latex }
|
||||
})
|
||||
// Callouts
|
||||
result = result.replace(/<div[^>]*data-type="callout-block"[^>]*data-callout-type="([^"]*)"[^>]*>/g, (_, type) => {
|
||||
const c: Record<string, string> = {
|
||||
info: '#eff6ff|#3b82f6', warning: '#fffbeb|#f59e0b', tip: '#faf5ff|#8b5cf6',
|
||||
success: '#f0fdf4|#22c55e', danger: '#fef2f2|#ef4444',
|
||||
}
|
||||
const [bg, border] = (c[type] || c.info).split('|')
|
||||
return `<div style="background:${bg};border-left:4px solid ${border};border-radius:8px;padding:12px 16px;margin:16px 0">`
|
||||
})
|
||||
// Remove outline blocks (need editor JS)
|
||||
result = result.replace(/<div[^>]*data-type="outline-block"[^>]*><\/div>/g, '')
|
||||
// Remove link preview searchable text
|
||||
result = result.replace(/<div[^>]*class="link-preview-searchable"[^>]*>[\s\S]*?<\/div>/g, '')
|
||||
// Remove editor buttons
|
||||
result = result.replace(/<button[^>]*>[\s\S]*?<\/button>/g, '')
|
||||
// Remove contentEditable attributes
|
||||
result = result.replace(/contenteditable="[^"]*"/gi, '')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function estimateReadingTime(html: string): number {
|
||||
const words = html.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).length
|
||||
return Math.max(1, Math.ceil(words / 200))
|
||||
}
|
||||
|
||||
export default async function PublishedNotePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const note = await getPublishedNote(slug)
|
||||
if (!note) notFound()
|
||||
|
||||
const processedContent = processContent(note.content || '')
|
||||
const readingTime = estimateReadingTime(note.content || '')
|
||||
|
||||
/* ─── MAGAZINE ──────────────────────────────────────────────────────────── */
|
||||
function MagazinePage({ note, bodyHtml, readingTime, slug, isStale }: PageProps) {
|
||||
return (
|
||||
<>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Source+Serif+4:opsz,wght@8..60,300..600&family=Inter:wght@400;500&display=swap" rel="stylesheet" />
|
||||
|
||||
<div className="mag-root">
|
||||
<nav className="mag-nav">
|
||||
<span className="mag-nav-brand">Memento</span>
|
||||
<div className="mag-nav-right">
|
||||
<span className="mag-badge"><Sparkles size={10} /> Article</span>
|
||||
<a href={`/p/${slug}/report`} className="mag-report"><Flag size={11} /> Signaler</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Header pleine largeur */}
|
||||
<header className="mag-header">
|
||||
<div className="mag-header-inner">
|
||||
<div className="mag-category">ARTICLE · {note.publishedAt ? format(new Date(note.publishedAt), 'd MMMM yyyy', { locale: fr }) : ''} · {readingTime} min</div>
|
||||
<h1 className="mag-title">{note.title || 'Sans titre'}</h1>
|
||||
{note.user?.name && (
|
||||
<div className="mag-author">
|
||||
{note.user.image && <img src={note.user.image} alt="" className="mag-avatar" />}
|
||||
<span>par <strong>{note.user.name}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mag-content">
|
||||
<div dir="auto" className="mag-body pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
</div>
|
||||
|
||||
<footer className="mag-footer">
|
||||
<span>Memento</span> — Votre mémoire augmentée par l’IA
|
||||
{isStale && <p className="mag-stale">Le contenu source a évolué depuis la dernière publication.</p>}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
.mag-root { min-height: 100vh; background: #fff; color: #111; font-family: 'Inter', sans-serif; }
|
||||
|
||||
.mag-nav {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: #111; color: #fff;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 28px;
|
||||
}
|
||||
.mag-nav-brand { font-family: 'Playfair Display', serif; font-size: 17px; font-weight: 900; letter-spacing: 0.04em; }
|
||||
.mag-nav-right { display: flex; align-items: center; gap: 12px; }
|
||||
.mag-badge { display: flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 600; letter-spacing: 0.12em; color: #f5c96a; text-transform: uppercase; }
|
||||
.mag-report { display: flex; align-items: center; gap: 4px; font-size: 11px; color: rgba(255,255,255,0.45); text-decoration: none; }
|
||||
.mag-report:hover { color: #fff; }
|
||||
|
||||
.mag-header { background: #111; color: #fff; padding: 64px 28px 72px; text-align: center; }
|
||||
.mag-header-inner { max-width: 780px; margin: 0 auto; }
|
||||
.mag-category { font-size: 11px; font-weight: 600; letter-spacing: 0.2em; color: #f5c96a; text-transform: uppercase; margin-bottom: 28px; }
|
||||
.mag-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: clamp(36px, 7vw, 72px);
|
||||
font-weight: 900; line-height: 1.05; letter-spacing: -0.02em;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.mag-author { display: inline-flex; align-items: center; gap: 10px; font-size: 14px; color: rgba(255,255,255,0.65); }
|
||||
.mag-avatar { width: 30px; height: 30px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.2); }
|
||||
|
||||
.mag-content { max-width: 760px; margin: 0 auto; padding: 64px 28px 80px; }
|
||||
|
||||
/* Body article magazine */
|
||||
.mag-body { font-family: 'Source Serif 4', Georgia, serif; font-size: 19px; line-height: 1.85; color: #1a1a1a; }
|
||||
.mag-body .pub-tpl { all: unset; display: block; }
|
||||
|
||||
.mag-body .pub-magazine-dek {
|
||||
font-size: 1.3em; line-height: 1.6; color: #333; margin-bottom: 2.5em;
|
||||
padding-bottom: 2em; border-bottom: 2px solid #111;
|
||||
font-style: italic;
|
||||
}
|
||||
.mag-body .pub-pull-quote {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: clamp(1.5em, 4vw, 2em); font-style: italic; font-weight: 700;
|
||||
color: #111; line-height: 1.35;
|
||||
margin: 2.5em 0; padding: 0;
|
||||
border: none; border-top: 4px solid #f5c96a; border-bottom: 4px solid #f5c96a;
|
||||
padding: 1.5em 0; text-align: center;
|
||||
}
|
||||
.mag-body .pub-pull-quote p { margin: 0; }
|
||||
|
||||
.mag-body .pub-hero { margin: 0 -28px 3rem; border-radius: 0; max-height: 560px; aspect-ratio: 16/9; background: #222; overflow: hidden; }
|
||||
.mag-body .pub-hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.mag-body .pub-body-source { }
|
||||
.mag-body h2 { font-family: 'Playfair Display', serif; font-size: 1.6em; font-weight: 700; margin-top: 2.5em; margin-bottom: 0.6em; }
|
||||
.mag-body h3 { font-size: 1.2em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; }
|
||||
.mag-body p { margin: 1.1em 0; }
|
||||
.mag-body blockquote { border-left: 4px solid #f5c96a; padding-left: 1.2em; margin: 1.5em 0; color: #444; font-style: italic; }
|
||||
.mag-body ul, .mag-body ol { padding-left: 1.5em; margin: 1em 0; }
|
||||
.mag-body li { margin: 0.4em 0; }
|
||||
.mag-body pre { background: #f6f6f4; padding: 20px; border-radius: 8px; overflow-x: auto; font-size: 14px; margin: 1.5em 0; }
|
||||
.mag-body code { font-family: 'SF Mono', Menlo, monospace; font-size: 0.88em; background: #f3f2ef; padding: 1px 5px; border-radius: 3px; }
|
||||
.mag-body pre code { background: none; padding: 0; }
|
||||
.mag-body table { border-collapse: collapse; width: 100%; margin: 1.5em 0; }
|
||||
.mag-body th { background: #111; color: #fff; padding: 10px 14px; text-align: left; font-size: 13px; letter-spacing: 0.05em; }
|
||||
.mag-body td { border-bottom: 1px solid #e5e5e5; padding: 10px 14px; }
|
||||
.mag-body a { color: #b8832a; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.mag-body .pub-figure { margin: 2.5em 0; text-align: center; }
|
||||
.mag-body .pub-figure-img { max-width: 100%; border-radius: 4px; box-shadow: 0 12px 40px rgba(0,0,0,0.12); }
|
||||
.mag-body .pub-figure-caption { margin-top: 0.6em; font-size: 0.82em; color: #888; font-style: italic; }
|
||||
.mag-body .pub-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin-top: 3em; }
|
||||
.mag-body .pub-gallery-img { width: 100%; height: 200px; object-fit: cover; border-radius: 4px; display: block; }
|
||||
.mag-body .pub-callout { border-radius: 6px; padding: 14px 18px; margin: 1.5em 0; }
|
||||
${KATEX_PUBLISH_CSS}
|
||||
|
||||
.mag-footer { background: #111; color: rgba(255,255,255,0.4); padding: 36px 28px; text-align: center; font-size: 13px; font-family: 'Playfair Display', serif; }
|
||||
.mag-footer span { color: #f5c96a; font-weight: 700; }
|
||||
.mag-stale { margin-top: 8px; font-size: 11px; color: rgba(255,255,255,0.25); font-family: 'Inter', sans-serif; }
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.mag-body .pub-hero { margin-left: -60px; margin-right: -60px; }
|
||||
.mag-content { padding-left: 60px; padding-right: 60px; }
|
||||
}
|
||||
|
||||
/* Variables magazine pour blocs réécriture — accents dorés, texte toujours lisible */
|
||||
.mag-body {
|
||||
--pub-accent: #9a7212;
|
||||
--pub-summary-color: #333;
|
||||
--pub-exercise-border: #e0d4c3;
|
||||
--pub-exercise-header-bg: #faf6ee;
|
||||
--pub-solution-bg: #faf6ee;
|
||||
--pub-definition-border: #e0d4c3;
|
||||
--pub-definition-bg: #fafaf8;
|
||||
--pub-toggle-border: #e5e5e5;
|
||||
--pub-toggle-header-bg: #f7f7f5;
|
||||
--pub-highlight-bg: #faf6ee;
|
||||
--pub-checklist-bg: rgba(0,0,0,0.03);
|
||||
}
|
||||
.mag-body .pub-rewrite-body .pub-code { background: #0d0d0d; }
|
||||
.mag-body .pub-rewrite-body .pub-code code { background: #0d0d0d; color: #e8e3d5; }
|
||||
.mag-body .pub-rewrite-body .pub-definition-term { background: #e8c96a; color: #1a1a1a; }
|
||||
.mag-body .pub-rewrite-body .pub-step-num { background: #9a7212; color: #fff; }
|
||||
.mag-body .pub-rewrite-body .pub-exercise-num { color: #9a7212; }
|
||||
.mag-body .pub-rewrite-body .pub-solution > summary { color: #9a7212; }
|
||||
.mag-body .pub-rewrite-body .pub-checklist li::before { color: #9a7212; }
|
||||
.mag-body .pub-magazine-dek { color: #333; }
|
||||
${REWRITE_SHARED_CSS}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── BRIEF ─────────────────────────────────────────────────────────────── */
|
||||
function BriefPage({ note, bodyHtml, readingTime, slug, isStale }: PageProps) {
|
||||
return (
|
||||
<>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
|
||||
<div className="br-root">
|
||||
<nav className="br-nav">
|
||||
<span className="br-brand">Memento</span>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<span className="br-badge"><Sparkles size={9} /> Fiche expert</span>
|
||||
<a href={`/p/${slug}/report`} className="br-report"><Flag size={11} /></a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header className="br-header">
|
||||
<div className="br-header-inner">
|
||||
<div className="br-meta">
|
||||
<span className="br-tag">Fiche expert</span>
|
||||
<span className="br-dot">·</span>
|
||||
<span>{readingTime} min de lecture</span>
|
||||
{note.publishedAt && <><span className="br-dot">·</span><span>{format(new Date(note.publishedAt), 'd MMM yyyy', { locale: fr })}</span></>}
|
||||
</div>
|
||||
<h1 className="br-title">{note.title || 'Sans titre'}</h1>
|
||||
{note.user?.name && (
|
||||
<div className="br-author">
|
||||
{note.user.image && <img src={note.user.image} alt="" className="br-avatar" />}
|
||||
<span>{note.user.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="br-layout">
|
||||
<div className="br-content">
|
||||
<div dir="auto" className="br-body pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="br-footer">
|
||||
<strong>Memento</strong> — Fiche générée par IA
|
||||
{isStale && <p style={{ fontSize: 11, color: '#aaa', marginTop: 6 }}>Le contenu source a évolué depuis la publication.</p>}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
.br-root { min-height: 100vh; background: #F4F6F9; color: #1a2433; font-family: 'DM Sans', system-ui, sans-serif; }
|
||||
|
||||
.br-nav {
|
||||
position: sticky; top: 0; z-index: 20; background: #fff;
|
||||
border-bottom: 1px solid #e8ecf2;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 28px;
|
||||
}
|
||||
.br-brand { font-weight: 700; font-size: 15px; color: #1a2433; }
|
||||
.br-badge { display: flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 600; color: #2563eb; background: #eff6ff; padding: 3px 8px; border-radius: 20px; letter-spacing: 0.04em; }
|
||||
.br-report { color: #aaa; text-decoration: none; display: flex; }
|
||||
.br-report:hover { color: #666; }
|
||||
|
||||
.br-header { background: linear-gradient(135deg, #1e3a5f 0%, #0f1f35 100%); color: #fff; padding: 56px 28px 60px; }
|
||||
.br-header-inner { max-width: 820px; margin: 0 auto; }
|
||||
.br-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; color: rgba(255,255,255,0.55); font-weight: 500; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 20px; }
|
||||
.br-tag { background: #2563eb; color: #fff; font-size: 10px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; padding: 3px 8px; border-radius: 4px; }
|
||||
.br-dot { color: rgba(255,255,255,0.3); }
|
||||
.br-title { font-size: clamp(24px, 4.5vw, 44px); font-weight: 700; line-height: 1.2; letter-spacing: -0.02em; margin-bottom: 24px; }
|
||||
.br-author { display: flex; align-items: center; gap: 8px; font-size: 13px; color: rgba(255,255,255,0.5); }
|
||||
.br-avatar { width: 26px; height: 26px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.2); }
|
||||
|
||||
.br-layout { max-width: 820px; margin: 0 auto; padding: 48px 28px 80px; }
|
||||
|
||||
.br-body { font-size: 16px; line-height: 1.75; color: #1a2433; }
|
||||
.br-body .pub-tpl { all: unset; display: block; }
|
||||
|
||||
/* Brief lead summary */
|
||||
.br-body .pub-brief-lead {
|
||||
background: #fff; border-radius: 12px;
|
||||
border-left: 5px solid #2563eb;
|
||||
padding: 24px 28px; margin-bottom: 2em;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.br-body .pub-brief-lead p { margin: 0; font-size: 1.08em; line-height: 1.75; color: #1a2433; font-weight: 500; }
|
||||
|
||||
/* Key points */
|
||||
.br-body .pub-key-points {
|
||||
background: #fff; border-radius: 12px; padding: 24px 28px;
|
||||
margin-bottom: 2em; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.br-body .pub-key-points-label {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.2em;
|
||||
font-weight: 700; color: #2563eb; margin-bottom: 14px; display: block;
|
||||
}
|
||||
.br-body .pub-key-points ul { list-style: none; padding: 0; margin: 0; }
|
||||
.br-body .pub-key-points li {
|
||||
padding: 10px 12px 10px 40px; border-radius: 8px;
|
||||
background: #f4f6f9; margin-bottom: 8px; position: relative;
|
||||
font-size: 0.95em; font-weight: 500;
|
||||
}
|
||||
.br-body .pub-key-points li::before {
|
||||
content: '✓'; position: absolute; left: 12px; top: 10px;
|
||||
color: #2563eb; font-weight: 700; font-size: 14px;
|
||||
}
|
||||
|
||||
/* Source body */
|
||||
.br-body .pub-body-source {
|
||||
background: #fff; border-radius: 12px; padding: 32px 36px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
.br-body .pub-hero { margin: 0 0 2rem; border-radius: 12px; overflow: hidden; max-height: 400px; aspect-ratio: 16/9; background: #dce3ed; }
|
||||
.br-body .pub-hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.br-body h2 { font-size: 1.25em; font-weight: 700; margin-top: 2em; margin-bottom: 0.6em; color: #0f1f35; border-bottom: 2px solid #e8ecf2; padding-bottom: 0.4em; }
|
||||
.br-body h3 { font-size: 1.08em; font-weight: 600; margin-top: 1.5em; margin-bottom: 0.4em; color: #1e3a5f; }
|
||||
.br-body p { margin: 0.9em 0; }
|
||||
.br-body blockquote { border-left: 4px solid #2563eb; padding-left: 1em; margin: 1.2em 0; color: #3d5a80; font-style: italic; }
|
||||
.br-body ul, .br-body ol { padding-left: 1.4em; margin: 1em 0; }
|
||||
.br-body li { margin: 0.4em 0; }
|
||||
.br-body pre { background: #1a2433; color: #e2e8f0; padding: 20px; border-radius: 10px; overflow-x: auto; font-size: 13px; margin: 1.5em 0; font-family: 'DM Mono', monospace; }
|
||||
.br-body code { font-family: 'DM Mono', monospace; font-size: 0.87em; background: #e8ecf2; padding: 1px 5px; border-radius: 3px; color: #1e3a5f; }
|
||||
.br-body pre code { background: none; color: inherit; padding: 0; }
|
||||
.br-body table { border-collapse: collapse; width: 100%; margin: 1.5em 0; border-radius: 8px; overflow: hidden; }
|
||||
.br-body th { background: #1e3a5f; color: #fff; padding: 10px 14px; text-align: left; font-size: 13px; }
|
||||
.br-body td { border-bottom: 1px solid #e8ecf2; padding: 10px 14px; }
|
||||
.br-body tr:last-child td { border-bottom: none; }
|
||||
.br-body a { color: #2563eb; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.br-body .pub-figure { margin: 2em 0; text-align: center; }
|
||||
.br-body .pub-figure-img { max-width: 100%; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
|
||||
.br-body .pub-figure-caption { margin-top: 0.5em; font-size: 0.82em; color: #7a8fa6; }
|
||||
.br-body .pub-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-top: 2em; }
|
||||
.br-body .pub-gallery-img { width: 100%; height: 160px; object-fit: cover; border-radius: 8px; display: block; }
|
||||
.br-body .pub-callout { border-radius: 8px; padding: 12px 16px; margin: 1.2em 0; }
|
||||
${KATEX_PUBLISH_CSS}
|
||||
|
||||
.br-footer { background: #0f1f35; color: rgba(255,255,255,0.35); padding: 32px 28px; text-align: center; font-size: 13px; }
|
||||
.br-footer strong { color: #fff; }
|
||||
|
||||
/* Variables brief pour blocs réécriture */
|
||||
.br-body {
|
||||
--pub-accent: #2563eb;
|
||||
--pub-summary-color: #3d5a80;
|
||||
--pub-exercise-border: #bfdbfe;
|
||||
--pub-exercise-header-bg: #eff6ff;
|
||||
--pub-solution-bg: #f0f7ff;
|
||||
--pub-definition-border: #bfdbfe;
|
||||
--pub-definition-bg: #f8faff;
|
||||
--pub-toggle-border: #e2e8f0;
|
||||
--pub-toggle-header-bg: #f8fafc;
|
||||
--pub-highlight-bg: #eff6ff;
|
||||
--pub-checklist-bg: #f4f6f9;
|
||||
}
|
||||
.br-body .pub-rewrite-body .pub-code { background: #1a2433; }
|
||||
.br-body .pub-rewrite-body .pub-code code { background: #1a2433; color: #e2e8f0; }
|
||||
.br-body .pub-rewrite-body .pub-body-source {
|
||||
background: #fff; border-radius: 12px; padding: 32px 36px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
${REWRITE_SHARED_CSS}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── ESSAY ──────────────────────────────────────────────────────────────── */
|
||||
function EssayPage({ note, bodyHtml, readingTime, slug, isStale }: PageProps) {
|
||||
return (
|
||||
<>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;1,400;1,600&family=Source+Serif+4:opsz,wght@8..60,300..600&family=Inter:wght@400;500&display=swap" rel="stylesheet" />
|
||||
|
||||
<div className="es-root">
|
||||
<nav className="es-nav">
|
||||
<span className="es-brand">Memento</span>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<span className="es-badge"><Sparkles size={10} /> Essai</span>
|
||||
<a href={`/p/${slug}/report`} className="es-report"><Flag size={11} /></a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="es-main">
|
||||
<div className="es-rule-top" />
|
||||
<header className="es-header">
|
||||
{note.publishedAt && (
|
||||
<time className="es-date">{format(new Date(note.publishedAt), 'd MMMM yyyy', { locale: fr })}</time>
|
||||
)}
|
||||
<h1 className="es-title">{note.title || 'Sans titre'}</h1>
|
||||
<div className="es-rule-thin" />
|
||||
{note.user?.name && (
|
||||
<div className="es-author">
|
||||
{note.user.image && <img src={note.user.image} alt="" className="es-avatar" />}
|
||||
<div>
|
||||
<span className="es-author-name">{note.user.name}</span>
|
||||
<span className="es-reading-time">{readingTime} min de lecture</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div dir="auto" className="es-body pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
|
||||
<div className="es-rule-thin" style={{ marginTop: '4rem' }} />
|
||||
<footer className="es-footer">
|
||||
Publié sur <strong>Memento</strong>
|
||||
{isStale && <p style={{ fontSize: 11, color: '#bbb', marginTop: 6 }}>Le contenu source a évolué depuis la publication.</p>}
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
.es-root { min-height: 100vh; background: #FDFBF7; color: #2c2416; font-family: 'Inter', sans-serif; }
|
||||
|
||||
.es-nav {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: rgba(253,251,247,0.9); backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid #e8e0d0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 40px;
|
||||
}
|
||||
.es-brand { font-family: 'Lora', Georgia, serif; font-size: 16px; font-style: italic; color: #5c4a2a; }
|
||||
.es-badge { display: flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; color: #8b6c3a; font-family: 'Inter', sans-serif; }
|
||||
.es-report { color: #bbb; text-decoration: none; display: flex; }
|
||||
.es-report:hover { color: #666; }
|
||||
|
||||
.es-main { max-width: 680px; margin: 0 auto; padding: 60px 36px 100px; }
|
||||
.es-rule-top { width: 60px; height: 3px; background: #5c4a2a; margin: 48px auto 0; }
|
||||
|
||||
.es-header { text-align: center; padding: 40px 0 36px; }
|
||||
.es-date { display: block; font-size: 12px; font-weight: 500; letter-spacing: 0.15em; text-transform: uppercase; color: #a08b5c; margin-bottom: 24px; }
|
||||
.es-title {
|
||||
font-family: 'Lora', Georgia, serif;
|
||||
font-size: clamp(28px, 5vw, 52px);
|
||||
font-weight: 600; font-style: italic; line-height: 1.2;
|
||||
letter-spacing: -0.01em; color: #1e160c;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.es-rule-thin { height: 1px; background: #d8cebc; margin: 0 auto; width: 100%; }
|
||||
.es-author { display: flex; align-items: center; gap: 12px; margin-top: 24px; justify-content: center; }
|
||||
.es-avatar { width: 40px; height: 40px; border-radius: 50%; border: 2px solid #d8cebc; }
|
||||
.es-author-name { display: block; font-size: 14px; font-weight: 600; color: #3d2f14; }
|
||||
.es-reading-time { display: block; font-size: 12px; color: #a08b5c; }
|
||||
|
||||
/* Essay body */
|
||||
.es-body { margin-top: 52px; font-family: 'Lora', Georgia, serif; font-size: 19px; line-height: 2; color: #2c2416; }
|
||||
.es-body .pub-tpl { all: unset; display: block; }
|
||||
|
||||
/* Epigraph avec guillemet décoratif */
|
||||
.es-body .pub-epigraph {
|
||||
position: relative;
|
||||
text-align: center; font-size: 1.15em; font-style: italic; color: #6b5337;
|
||||
margin: 0 0 3em; padding: 1.5em 2.5em 1em;
|
||||
border-top: 1px solid #d8cebc; border-bottom: 1px solid #d8cebc;
|
||||
}
|
||||
.es-body .pub-epigraph::before {
|
||||
content: '"';
|
||||
position: absolute; top: -0.3em; left: 50%; transform: translateX(-50%);
|
||||
font-family: 'Lora', Georgia, serif; font-size: 5em; color: #d8cebc;
|
||||
line-height: 1; pointer-events: none; font-style: normal;
|
||||
}
|
||||
|
||||
.es-body .pub-essay-summary {
|
||||
font-size: 1.15em; line-height: 1.8; color: #4a3726;
|
||||
margin-bottom: 2.5em; font-style: italic;
|
||||
padding-left: 1.5em; border-left: 2px solid #c8b99a;
|
||||
}
|
||||
|
||||
.es-body .pub-body-source p { text-indent: 1.8em; }
|
||||
.es-body .pub-body-source p:first-of-type { text-indent: 0; }
|
||||
.es-body .pub-body-source > p:first-child { text-indent: 0; }
|
||||
|
||||
.es-body .pub-hero { margin: 0 -36px 3em; overflow: hidden; border-radius: 0; max-height: 480px; aspect-ratio: 16/9; background: #e8e0d0; }
|
||||
.es-body .pub-hero-img { width: 100%; height: 100%; object-fit: cover; display: block; filter: sepia(10%); }
|
||||
.es-body h2 { font-family: 'Lora', serif; font-size: 1.45em; font-style: italic; font-weight: 600; margin-top: 2.5em; margin-bottom: 0.7em; color: #1e160c; }
|
||||
.es-body h3 { font-family: 'Lora', serif; font-size: 1.2em; font-weight: 600; margin-top: 2em; margin-bottom: 0.5em; }
|
||||
.es-body p { margin: 0 0 0.2em; }
|
||||
.es-body blockquote {
|
||||
border: none; margin: 2em 0; padding: 0 2em;
|
||||
font-size: 1.15em; color: #5c4a2a; font-style: italic;
|
||||
text-align: center; position: relative;
|
||||
}
|
||||
.es-body ul, .es-body ol { padding-left: 1.4em; margin: 1em 0; }
|
||||
.es-body li { margin: 0.5em 0; }
|
||||
.es-body pre { background: #f2ede4; padding: 20px; border-radius: 6px; overflow-x: auto; font-family: 'SF Mono', Menlo, monospace; font-size: 13px; margin: 1.5em 0; }
|
||||
.es-body code { font-family: 'SF Mono', Menlo, monospace; font-size: 0.85em; background: #ede7da; padding: 1px 5px; border-radius: 3px; }
|
||||
.es-body pre code { background: none; padding: 0; }
|
||||
.es-body table { border-collapse: collapse; width: 100%; margin: 1.5em 0; }
|
||||
.es-body th { border-bottom: 2px solid #5c4a2a; padding: 8px 12px; text-align: left; font-size: 14px; color: #5c4a2a; }
|
||||
.es-body td { border-bottom: 1px solid #e8e0d0; padding: 8px 12px; }
|
||||
.es-body a { color: #7a5c2e; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px; }
|
||||
.es-body .pub-figure { margin: 2.5em 0; text-align: center; }
|
||||
.es-body .pub-figure-img { max-width: 100%; border-radius: 2px; box-shadow: 0 6px 24px rgba(0,0,0,0.1); filter: sepia(5%); }
|
||||
.es-body .pub-figure-caption { margin-top: 0.65em; font-size: 0.8em; color: #9a8470; font-style: italic; }
|
||||
.es-body .pub-gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-top: 2.5em; }
|
||||
.es-body .pub-gallery-img { width: 100%; height: 160px; object-fit: cover; border-radius: 2px; display: block; filter: sepia(8%); }
|
||||
.es-body .pub-callout { border-radius: 4px; padding: 12px 16px; margin: 1.5em 0; }
|
||||
${KATEX_PUBLISH_CSS}
|
||||
|
||||
.es-footer { text-align: center; padding: 32px 0 0; font-size: 13px; color: #a08b5c; font-family: 'Inter', sans-serif; }
|
||||
.es-footer strong { color: #5c4a2a; }
|
||||
|
||||
/* Variables essay pour blocs réécriture */
|
||||
.es-body {
|
||||
--pub-accent: #7a5c2e;
|
||||
--pub-summary-color: #5c4a2a;
|
||||
--pub-exercise-border: #d8cebc;
|
||||
--pub-exercise-header-bg: #f5f0e8;
|
||||
--pub-solution-bg: rgba(122,92,46,0.05);
|
||||
--pub-definition-border: #d8cebc;
|
||||
--pub-definition-bg: #fdfbf7;
|
||||
--pub-toggle-border: #e2d9ca;
|
||||
--pub-toggle-header-bg: #f7f3ec;
|
||||
--pub-highlight-bg: #fdf6eb;
|
||||
--pub-checklist-bg: rgba(0,0,0,0.025);
|
||||
}
|
||||
.es-body .pub-rewrite-body .pub-code { background: #2c2416; }
|
||||
.es-body .pub-rewrite-body .pub-code code { background: #2c2416; color: #f0e8da; }
|
||||
${REWRITE_SHARED_CSS}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── SIMPLE (pas d'IA) ─────────────────────────────────────────────────── */
|
||||
function SimplePage({ note, bodyHtml, readingTime, slug }: PageProps) {
|
||||
return (
|
||||
<>
|
||||
{/* KaTeX + fonts CSS */}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,300..600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
|
||||
<div style={{ minHeight: '100vh', background: '#FAF9F5', color: '#1a1a1a', fontFamily: "'Inter', sans-serif" }}>
|
||||
{/* Top bar */}
|
||||
<div style={{
|
||||
position: 'sticky', top: 0, zIndex: 10,
|
||||
background: 'rgba(250,249,245,0.85)', backdropFilter: 'blur(12px)',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.06)', padding: '10px 24px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<span style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 600, fontSize: '15px' }}>Momento</span>
|
||||
<a href={`/p/${slug}/report`} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#888', textDecoration: 'none', padding: '4px 10px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Flag size={12} /> Signaler
|
||||
</a>
|
||||
<div style={{ position: 'sticky', top: 0, zIndex: 10, background: 'rgba(250,249,245,0.9)', backdropFilter: 'blur(12px)', borderBottom: '1px solid rgba(0,0,0,0.06)', padding: '10px 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 600, fontSize: '15px' }}>Memento</span>
|
||||
<a href={`/p/${slug}/report`} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#888', textDecoration: 'none', padding: '4px 10px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.1)' }}><Flag size={12} /> Signaler</a>
|
||||
</div>
|
||||
|
||||
{/* Article */}
|
||||
<article style={{ maxWidth: '720px', margin: '0 auto', padding: '48px 24px 80px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', color: '#999', marginBottom: '16px' }}>
|
||||
<Calendar size={12} />
|
||||
<span>{note.publishedAt ? format(new Date(note.publishedAt), 'd MMM yyyy') : ''}</span>
|
||||
<span>·</span>
|
||||
<Clock size={12} />
|
||||
<span>{readingTime} min de lecture</span>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginBottom: '16px' }}>
|
||||
{note.publishedAt ? format(new Date(note.publishedAt), 'd MMM yyyy', { locale: fr }) : ''} · {readingTime} min de lecture
|
||||
</div>
|
||||
|
||||
<h1 style={{ fontFamily: "'Source Serif 4', Georgia, serif", fontSize: 'clamp(28px,5vw,40px)', fontWeight: 600, lineHeight: 1.2, letterSpacing: '-0.02em', margin: '0 0 12px' }}>
|
||||
{note.title || 'Sans titre'}
|
||||
</h1>
|
||||
|
||||
<h1 style={{ fontFamily: "'Source Serif 4', Georgia, serif", fontSize: 'clamp(28px,5vw,40px)', fontWeight: 600, lineHeight: 1.2, letterSpacing: '-0.02em', margin: '0 0 12px' }}>{note.title || 'Sans titre'}</h1>
|
||||
{note.user?.name && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '40px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '36px' }}>
|
||||
{note.user.image && <img src={note.user.image} alt="" style={{ width: '28px', height: '28px', borderRadius: '50%' }} />}
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>par {note.user.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ height: '1px', background: 'rgba(0,0,0,0.08)', marginBottom: '40px' }} />
|
||||
|
||||
<div dir="auto" style={{ fontSize: '16px', lineHeight: 1.8 }} dangerouslySetInnerHTML={{ __html: sanitizeRichHtml(processedContent) }} />
|
||||
|
||||
<div style={{ height: '1px', background: 'rgba(0,0,0,0.08)', marginBottom: '36px' }} />
|
||||
<div dir="auto" className="pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
<style>{`
|
||||
article h1, article h2, article h3 { font-family: 'Source Serif 4', Georgia, serif; font-weight: 600; letter-spacing: -0.01em; margin-top: 1.8em; margin-bottom: 0.6em; }
|
||||
article h2 { font-size: 1.5em; }
|
||||
article h3 { font-size: 1.2em; }
|
||||
article p { margin: 1em 0; }
|
||||
article ul, article ol { padding-left: 1.5em; margin: 1em 0; }
|
||||
article li { margin: 0.4em 0; }
|
||||
article blockquote { border-left: 3px solid #A47148; padding-left: 1em; margin: 1.2em 0; color: #555; font-style: italic; }
|
||||
article pre { background: #f4f3ef; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 14px; margin: 1.2em 0; }
|
||||
article code { font-family: 'SF Mono', Menlo, monospace; }
|
||||
article table { border-collapse: collapse; width: 100%; margin: 1.2em 0; }
|
||||
article th, article td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
|
||||
article th { background: #f4f3ef; font-weight: 600; }
|
||||
article img { max-width: 100%; height: auto; border-radius: 8px; margin: 1em 0; }
|
||||
article a { color: #A47148; text-decoration: none; }
|
||||
article a:hover { text-decoration: underline; }
|
||||
.r-math-display { margin: 1.5em 0; text-align: center; overflow-x: auto; }
|
||||
.pub-article { font-family: 'Source Serif 4', Georgia, serif; font-size: 17px; line-height: 1.85; }
|
||||
.pub-article h2, .pub-article h3 { font-weight: 600; margin-top: 1.8em; margin-bottom: 0.6em; }
|
||||
.pub-article h2 { font-size: 1.4em; }
|
||||
.pub-article h3 { font-size: 1.15em; }
|
||||
.pub-article p { margin: 1em 0; }
|
||||
.pub-article ul, .pub-article ol { padding-left: 1.5em; margin: 1em 0; }
|
||||
.pub-article li { margin: 0.4em 0; }
|
||||
.pub-article blockquote { border-left: 3px solid #A47148; padding-left: 1em; margin: 1.2em 0; color: #555; font-style: italic; }
|
||||
.pub-article pre { background: #f4f3ef; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 14px; margin: 1.2em 0; }
|
||||
.pub-article code { font-family: 'SF Mono', Menlo, monospace; font-size: 0.87em; }
|
||||
.pub-article table { border-collapse: collapse; width: 100%; margin: 1.2em 0; }
|
||||
.pub-article th, .pub-article td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
|
||||
.pub-article th { background: #f4f3ef; font-weight: 600; }
|
||||
.pub-article a { color: #A47148; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.pub-article .pub-figure { margin: 2em 0; text-align: center; }
|
||||
.pub-article .pub-figure-img { max-width: 100%; border-radius: 8px; box-shadow: 0 6px 24px rgba(0,0,0,0.08); }
|
||||
.pub-article .pub-figure-caption { margin-top: 0.6em; font-size: 0.82em; color: #888; font-style: italic; }
|
||||
.pub-article .pub-callout { border-radius: 8px; padding: 12px 16px; margin: 1em 0; }
|
||||
${KATEX_PUBLISH_CSS}
|
||||
`}</style>
|
||||
</article>
|
||||
|
||||
{/* Footer */}
|
||||
<footer style={{ borderTop: '1px solid rgba(0,0,0,0.08)', padding: '32px 24px', textAlign: 'center', fontSize: '13px', color: '#999' }}>
|
||||
<span style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 600, color: '#A47148' }}>Momento</span>
|
||||
<footer style={{ borderTop: '1px solid rgba(0,0,0,0.08)', padding: '28px 24px', textAlign: 'center', fontSize: '13px', color: '#999' }}>
|
||||
<span style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 600, color: '#A47148' }}>Memento</span>
|
||||
{' — Votre mémoire augmentée par l\'IA'}
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Types ─────────────────────────────────────────────────────────────── */
|
||||
type NoteData = NonNullable<Awaited<ReturnType<typeof getPublishedNote>>>
|
||||
interface PageProps {
|
||||
note: NoteData
|
||||
bodyHtml: string
|
||||
readingTime: number
|
||||
slug: string
|
||||
isStale: boolean
|
||||
}
|
||||
|
||||
/* ─── Route ──────────────────────────────────────────────────────────────── */
|
||||
export default async function PublishedNotePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const note = await getPublishedNote(slug)
|
||||
if (!note) notFound()
|
||||
|
||||
const usesAiLayout = Boolean(note.publishedContent)
|
||||
const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||
// Toujours passer par processNoteHtmlForPublish : KaTeX + sanitisation compatible équations
|
||||
const bodyHtml = processNoteHtmlForPublish(rawHtml)
|
||||
|
||||
const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '')
|
||||
const readingTime = estimateReadingTime(readingSource)
|
||||
const isStale = Boolean(
|
||||
note.publishedSourceHash
|
||||
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
|
||||
)
|
||||
|
||||
const props: PageProps = { note, bodyHtml, readingTime, slug, isStale }
|
||||
|
||||
if (!usesAiLayout) return <SimplePage {...props} />
|
||||
if (note.publishedTemplate === 'brief') return <BriefPage {...props} />
|
||||
if (note.publishedTemplate === 'essay') return <EssayPage {...props} />
|
||||
return <MagazinePage {...props} />
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { revalidatePath } from 'next/cache'
|
||||
|
||||
type SvgComplexity = 'simple' | 'illustrated' | 'rich'
|
||||
|
||||
// Palette de l'application Momento — à utiliser dans TOUS les SVGs
|
||||
// Palette de l'application Memento — à utiliser dans TOUS les SVGs
|
||||
const APP_PALETTE = `
|
||||
APP COLOR PALETTE (use ONLY these colors — no other palettes):
|
||||
- Background warm beige: #F2F0E9
|
||||
|
||||
@@ -71,6 +71,9 @@ export async function getPublishedNote(slug: string) {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
publishedContent: true,
|
||||
publishedTemplate: true,
|
||||
publishedSourceHash: true,
|
||||
publishedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ExecuteResult {
|
||||
/**
|
||||
* Analyze a notebook's notes with AI and suggest a sub-notebook organization plan.
|
||||
*/
|
||||
export async function analyzeNotebookForOrganization(notebookId: string): Promise<AnalyzeResult> {
|
||||
export async function analyzeNotebookForOrganization(notebookId: string, language?: string): Promise<AnalyzeResult> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { success: false, error: 'Non autorisé' }
|
||||
|
||||
@@ -94,6 +94,9 @@ export async function analyzeNotebookForOrganization(notebookId: string): Promis
|
||||
? `\nSous-carnets existants:\n${existingSubs.map(s => `- "${s.name}" (id: ${s.id})`).join('\n')}`
|
||||
: '\nIl n\'y a pas encore de sous-carnets.'
|
||||
|
||||
const lang = language || 'fr'
|
||||
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang === 'it' ? 'Italiano' : lang === 'pt' ? 'Português' : lang === 'ru' ? 'Русский' : lang === 'zh' ? '中文' : lang === 'ja' ? '日本語' : lang === 'ko' ? '한국어' : lang === 'nl' ? 'Nederlands' : lang === 'pl' ? 'Polski' : lang === 'hi' ? 'हिन्दी' : lang
|
||||
|
||||
const prompt = `Tu es un assistant d'organisation de notes. Analyse les notes suivantes du carnet "${notebook.name}" et regroupe-les par thème en proposant des sous-carnets.
|
||||
${existingSubsContext}
|
||||
|
||||
@@ -104,7 +107,7 @@ RÈGLES IMPORTANTES:
|
||||
- Regroupe les notes par thème ou sujet similaire.
|
||||
- Propose entre 2 et 6 groupes maximum.
|
||||
- Si un sous-carnet existant correspond déjà à un thème, utilise-le (indique son id).
|
||||
- Les noms de groupe doivent être courts (2-4 mots), clairs et en français.
|
||||
- Les noms de groupe doivent être courts (2-4 mots), clairs et EN ${langName.toUpperCase()}.
|
||||
- N'inclus PAS toutes les notes si certaines sont trop générales ou ne correspondent à aucun groupe clair — laisse-les de côté.
|
||||
- Réponds UNIQUEMENT avec du JSON valide, sans markdown, sans explication.
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function DELETE(request: NextRequest) {
|
||||
where: { id: noteId },
|
||||
select: { userId: true, publicSlug: true },
|
||||
})
|
||||
if (note) {
|
||||
if (note && note.userId) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: note.userId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import prisma from '@/lib/prisma'
|
||||
import { exerciseGeneratorService } from '@/lib/ai/services/exercise-generator.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { preprocessMathInHtml } from '@/lib/text/math-preprocess'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -17,6 +18,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'noteId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) return aiConsentForbiddenResponse()
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||
} catch (err) {
|
||||
@@ -47,19 +50,27 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Create exercise notes in the same notebook
|
||||
const lang = language || 'fr'
|
||||
const exerciseLabel = lang === 'fr' ? 'Exercice' : lang === 'fa' ? 'تمرین' : 'Exercise'
|
||||
const answerLabel = lang === 'fr' ? 'Corrigé' : lang === 'fa' ? 'پاسخ' : 'Answer'
|
||||
const exerciseLabel = lang === 'fr' ? 'Exercice' : lang === 'fa' ? 'تمرین' : lang === 'ar' ? 'تمرين' : lang === 'de' ? 'Übung' : lang === 'es' ? 'Ejercicio' : lang === 'it' ? 'Esercizio' : lang === 'pt' ? 'Exercício' : lang === 'ru' ? 'Упражнение' : lang === 'zh' ? '练习' : lang === 'ja' ? '練習' : lang === 'ko' ? '연습' : lang === 'nl' ? 'Oefening' : lang === 'pl' ? 'Ćwiczenie' : lang === 'hi' ? 'अभ्यास' : 'Exercise'
|
||||
const answerLabel = lang === 'fr' ? 'Corrigé' : lang === 'fa' ? 'پاسخ' : lang === 'ar' ? 'الحل' : lang === 'de' ? 'Lösung' : lang === 'es' ? 'Solución' : lang === 'it' ? 'Soluzione' : lang === 'pt' ? 'Solução' : lang === 'ru' ? 'Решение' : lang === 'zh' ? '解答' : lang === 'ja' ? '解答' : lang === 'ko' ? '해답' : lang === 'nl' ? 'Oplossing' : lang === 'pl' ? 'Rozwiązanie' : lang === 'hi' ? 'हल' : 'Solution'
|
||||
const statementLabel = lang === 'fr' ? 'Énoncé' : lang === 'fa' ? 'صورت مسئله' : lang === 'ar' ? 'السؤال' : lang === 'de' ? 'Aufgabe' : lang === 'es' ? 'Enunciado' : lang === 'it' ? 'Testo' : lang === 'pt' ? 'Enunciado' : lang === 'ru' ? 'Задание' : lang === 'zh' ? '题目' : lang === 'ja' ? '問題' : lang === 'ko' ? '문제' : lang === 'nl' ? 'Opgave' : lang === 'pl' ? 'Zadanie' : lang === 'hi' ? 'प्रश्न' : 'Statement'
|
||||
const revealLabel = lang === 'fr' ? 'cliquer pour révéler' : lang === 'fa' ? 'برای نمایش کلیک کنید' : lang === 'ar' ? 'انقر للكشف' : lang === 'de' ? 'zum Anzeigen klicken' : lang === 'es' ? 'haz clic para ver' : lang === 'it' ? 'clicca per rivelare' : lang === 'pt' ? 'clique para revelar' : lang === 'ru' ? 'нажмите, чтобы открыть' : lang === 'zh' ? '点击查看' : lang === 'ja' ? 'クリックして表示' : lang === 'ko' ? '클릭하여 보기' : lang === 'nl' ? 'klik om te onthullen' : lang === 'pl' ? 'kliknij, aby zobaczyć' : lang === 'hi' ? 'देखने के लिए क्लिक करें' : 'click to reveal'
|
||||
const difficultyMap: Record<string, Record<string, string>> = {
|
||||
facile: { fr: 'facile', en: 'easy', fa: 'آسان', ar: 'سهل', de: 'einfach', es: 'fácil', it: 'facile', pt: 'fácil', ru: 'лёгкий', zh: '简单', ja: '易しい', ko: '쉬움', nl: 'makkelijk', pl: 'łatwy', hi: 'आसान' },
|
||||
moyen: { fr: 'moyen', en: 'medium', fa: 'متوسط', ar: 'متوسط', de: 'mittel', es: 'medio', it: 'medio', pt: 'médio', ru: 'средний', zh: '中等', ja: '普通', ko: '보통', nl: 'gemiddeld', pl: 'średni', hi: 'मध्यम' },
|
||||
difficile: { fr: 'difficile', en: 'hard', fa: 'دشوار', ar: 'صعب', de: 'schwer', es: 'difícil', it: 'difficile', pt: 'difícil', ru: 'сложный', zh: '困难', ja: '難しい', ko: '어려움', nl: 'moeilijk', pl: 'trudny', hi: 'कठिन' },
|
||||
}
|
||||
|
||||
const createdNotes = []
|
||||
for (let i = 0; i < exercises.length; i++) {
|
||||
const ex = exercises[i]
|
||||
const difficultyEmoji = ex.difficulty === 'facile' ? '🟢' : ex.difficulty === 'moyen' ? '🟡' : '🔴'
|
||||
const difficultyLocalized = (difficultyMap[ex.difficulty]?.[lang]) || ex.difficulty
|
||||
|
||||
const rawContent = `
|
||||
<div data-type="callout-block" data-callout-type="warning"><p><strong>${exerciseLabel} ${i + 1}</strong> — ${difficultyEmoji} ${ex.difficulty}</p></div>
|
||||
<h2>Énoncé</h2>
|
||||
<div data-type="callout-block" data-callout-type="warning"><p><strong>${exerciseLabel} ${i + 1}</strong> — ${difficultyEmoji} ${difficultyLocalized}</p></div>
|
||||
<h2>${statementLabel}</h2>
|
||||
<p>${ex.question}</p>
|
||||
<div data-type="toggle-block" data-opened="false"><p><strong>${answerLabel}</strong> — cliquer pour révéler</p><h3>Solution</h3><p>${ex.answer}</p></div>
|
||||
<div data-type="toggle-block" data-opened="false"><p><strong>${answerLabel}</strong> — ${revealLabel}</p><h3>${answerLabel}</h3><p>${ex.answer}</p></div>
|
||||
`.trim()
|
||||
|
||||
const content = preprocessMathInHtml(rawContent)
|
||||
|
||||
77
memento-note/app/api/ai/notebook-publish/route.ts
Normal file
77
memento-note/app/api/ai/notebook-publish/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { notebookPublishAnalyzerService } from '@/lib/ai/services/notebook-publish-analyzer.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { notebookId, language } = await request.json()
|
||||
if (!notebookId) {
|
||||
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) return aiConsentForbiddenResponse()
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'publish_enhance')
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
const isTierLocked = err.currentQuota === 0
|
||||
return NextResponse.json(
|
||||
{ error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded' },
|
||||
{ status: 402 },
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: notebookId, userId: session.user.id, trashedAt: null },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
if (!notebook) return NextResponse.json({ error: 'Notebook not found' }, { status: 404 })
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { notebookId, userId: session.user.id, trashedAt: null, isArchived: false },
|
||||
select: { id: true, title: true, content: true },
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
|
||||
if (notes.length === 0) {
|
||||
return NextResponse.json({ error: 'No notes found' }, { status: 400 })
|
||||
}
|
||||
|
||||
const notesForService = notes.map(n => {
|
||||
const stripped = (n.content || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const wordCount = stripped.split(' ').filter(Boolean).length
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title || '',
|
||||
wordCount,
|
||||
preview: stripped.slice(0, 200),
|
||||
}
|
||||
})
|
||||
|
||||
const analysis = await notebookPublishAnalyzerService.analyze(
|
||||
notebook.name,
|
||||
notesForService,
|
||||
language || 'fr',
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
notes: analysis.notes,
|
||||
description: analysis.description,
|
||||
noteTitles: Object.fromEntries(notes.map(n => [n.id, n.title || ''])),
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('[notebook-publish/analyze] Error:', error)
|
||||
return NextResponse.json({ error: error.message || 'Failed to analyze' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { notebookWizardService, type WizardProfile } from '@/lib/ai/services/notebook-wizard.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -17,6 +18,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Profile and topic are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) return aiConsentForbiddenResponse()
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { studyPlannerService } from '@/lib/ai/services/study-planner.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -16,6 +17,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'notebookId and examDate are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!(await hasUserAiConsent())) return aiConsentForbiddenResponse()
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'reformulate')
|
||||
} catch (err) {
|
||||
@@ -41,15 +44,18 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const userLang = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { theme: true },
|
||||
select: { language: true },
|
||||
})
|
||||
|
||||
const notesForService = notes.map(n => ({ id: n.id, title: n.title ?? '' }))
|
||||
const plan = await studyPlannerService.generate(notesForService, examDate)
|
||||
const plan = await studyPlannerService.generate(notesForService, examDate, userLang?.language || 'fr')
|
||||
|
||||
// Set reminders on notes based on the plan
|
||||
// Set the first occurrence reminder for each note (subsequent occurrences ignored)
|
||||
const seenNoteIds = new Set<string>()
|
||||
for (const day of plan.days) {
|
||||
for (const noteId of day.noteIds) {
|
||||
if (seenNoteIds.has(noteId)) continue
|
||||
seenNoteIds.add(noteId)
|
||||
try {
|
||||
const reminderDate = new Date(day.date)
|
||||
reminderDate.setHours(9, 0, 0, 0)
|
||||
|
||||
@@ -277,10 +277,10 @@ Fais un résumé concis (max 200 mots) de cette conversation. Garde les informat
|
||||
- Natural tone, neither corporate nor too casual.
|
||||
- No unnecessary intro phrases. Answer directly.
|
||||
- No upsell questions at the end. If you have useful additional info, just give it.
|
||||
- If the user says "Momento" they mean Momento (this app).
|
||||
- If the user says "Memento" they mean Memento (this app).
|
||||
|
||||
## About Momento
|
||||
Momento is an intelligent note-taking application. Key features include:
|
||||
## About Memento
|
||||
Memento is an intelligent note-taking application. Key features include:
|
||||
- **Notes & Editor**: Create rich Markdown notes with an integrated AI Copilot to rewrite, summarize, or translate content.
|
||||
- **Organization**: Group notes into Notebooks and tag them with Labels.
|
||||
- **Search**: Advanced semantic search to find notes by meaning, not just keywords, and Web Search integration.
|
||||
@@ -336,10 +336,10 @@ Available types: bar, horizontal-bar, line, area, pie, radar. NEVER use Mermaid
|
||||
## Règles de ton
|
||||
- Ton naturel, direct, sans phrases d'intro inutiles.
|
||||
- Pas de question upsell à la fin.
|
||||
- Si l'utilisateur dit "Momento" il parle de Momento (cette application).
|
||||
- Si l'utilisateur dit "Memento" il parle de Memento (cette application).
|
||||
|
||||
## À propos de Momento
|
||||
Momento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
|
||||
## À propos de Memento
|
||||
Memento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
|
||||
|
||||
## Outils disponibles
|
||||
Tu as accès à : note_search, note_read, note_find_and_update, document_search, task_extract, web_search, web_scrape, insert_chart.
|
||||
@@ -387,7 +387,7 @@ Types disponibles : bar, horizontal-bar, line, area, pie, radar. JAMAIS utiliser
|
||||
|
||||
## قوانین لحن
|
||||
- لحن طبیعی، مستقیم، بدون مقدمه اضافی.
|
||||
- اگر کاربر "Momento" میگوید، منظورش Memento (این برنامه) است.`,
|
||||
- اگر کاربر "Memento" میگوید، منظورش Memento (این برنامه) است.`,
|
||||
},
|
||||
es: {
|
||||
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis.`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* POST /api/integrations/readwise/sync
|
||||
* Syncs Readwise highlights into Momento notes.
|
||||
* Syncs Readwise highlights into Memento notes.
|
||||
* Each book/article becomes a note with all its highlights listed.
|
||||
*
|
||||
* Query params:
|
||||
|
||||
116
memento-note/app/api/notebooks/[id]/publish/route.ts
Normal file
116
memento-note/app/api/notebooks/[id]/publish/route.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const { id: notebookId } = await params
|
||||
const site = await prisma.notebookSite.findUnique({
|
||||
where: { notebookId },
|
||||
select: { slug: true, isPublic: true, publishedAt: true, template: true, selectedNoteIds: true },
|
||||
})
|
||||
return NextResponse.json({ site: site || null })
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function toSlug(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.slice(0, 40)
|
||||
}
|
||||
|
||||
function generateNotebookSlug(name: string): string {
|
||||
const base = toSlug(name) || 'carnet'
|
||||
const suffix = Math.random().toString(36).slice(2, 7)
|
||||
return `${base}-${suffix}`
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: notebookId } = await params
|
||||
const { selectedNoteIds, template, description } = await request.json()
|
||||
|
||||
if (!selectedNoteIds || !Array.isArray(selectedNoteIds) || selectedNoteIds.length === 0) {
|
||||
return NextResponse.json({ error: 'selectedNoteIds is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: notebookId, userId: session.user.id, trashedAt: null },
|
||||
select: { id: true, name: true, site: { select: { slug: true } } },
|
||||
})
|
||||
if (!notebook) return NextResponse.json({ error: 'Notebook not found' }, { status: 404 })
|
||||
|
||||
// Reuse existing slug or generate a new one
|
||||
const slug = notebook.site?.slug || generateNotebookSlug(notebook.name)
|
||||
|
||||
const site = await prisma.notebookSite.upsert({
|
||||
where: { notebookId },
|
||||
create: {
|
||||
notebookId,
|
||||
slug,
|
||||
isPublic: true,
|
||||
template: template || 'magazine',
|
||||
selectedNoteIds: JSON.stringify(selectedNoteIds),
|
||||
description: description || null,
|
||||
},
|
||||
update: {
|
||||
isPublic: true,
|
||||
template: template || 'magazine',
|
||||
selectedNoteIds: JSON.stringify(selectedNoteIds),
|
||||
description: description || null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ slug: site.slug, url: `/c/${site.slug}` })
|
||||
} catch (error: any) {
|
||||
console.error('[notebooks/publish] Error:', error)
|
||||
return NextResponse.json({ error: error.message || 'Failed to publish' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id: notebookId } = await params
|
||||
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: notebookId, userId: session.user.id },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!notebook) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
await prisma.notebookSite.deleteMany({ where: { notebookId } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { contentModerationService } from '@/lib/ai/services/content-moderation.service'
|
||||
import { contentModerationService, type ModerationResult } from '@/lib/ai/services/content-moderation.service'
|
||||
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
|
||||
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
|
||||
import { hasUserAiConsent } from '@/lib/consent/server-consent'
|
||||
import { isPublishTemplateId } from '@/lib/publish/types'
|
||||
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
|
||||
|
||||
const MODERATION_TIMEOUT_MS = 12_000
|
||||
|
||||
async function moderateWithFallback(title: string, content: string): Promise<ModerationResult> {
|
||||
try {
|
||||
return await Promise.race([
|
||||
contentModerationService.moderate(title, content),
|
||||
new Promise<ModerationResult>((resolve) => {
|
||||
setTimeout(() => resolve({
|
||||
verdict: 'safe',
|
||||
categories: ['safe'],
|
||||
reason: 'Moderation timeout',
|
||||
}), MODERATION_TIMEOUT_MS)
|
||||
}),
|
||||
])
|
||||
} catch {
|
||||
return { verdict: 'safe', categories: ['safe'], reason: 'Moderation indisponible' }
|
||||
}
|
||||
}
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
const base = title
|
||||
@@ -14,11 +38,70 @@ function generateSlug(title: string): string {
|
||||
return `${base}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
async function ensureSlug(noteId: string, title: string, existingSlug: string | null): Promise<string> {
|
||||
let slug = existingSlug
|
||||
if (!slug) {
|
||||
slug = generateSlug(title || 'note')
|
||||
const existing = await prisma.note.findUnique({ where: { publicSlug: slug } })
|
||||
if (existing && existing.id !== noteId) slug = `${slug}-${Date.now().toString(36)}`
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
async function notifyFlaggedAdmins(noteId: string, title: string, reason: string) {
|
||||
const admins = await prisma.user.findMany({ where: { role: 'ADMIN' }, select: { id: true } })
|
||||
for (const admin of admins) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
type: 'content_flagged',
|
||||
title: 'Contenu sensible publié',
|
||||
message: `La note "${title}" a été publiée avec un contenu potentiellement sensible: ${reason}`,
|
||||
actionUrl: '/admin/published',
|
||||
relatedId: noteId,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
type PublishUpdateData = {
|
||||
isPublic: boolean
|
||||
publicSlug: string | null
|
||||
publishedAt: Date | null
|
||||
publishedContent?: string | null
|
||||
publishedTemplate?: string | null
|
||||
publishedSourceHash?: string | null
|
||||
}
|
||||
|
||||
/** Tolerates stale Prisma client during dev (before server restart). */
|
||||
async function updateNotePublishState(noteId: string, data: PublishUpdateData) {
|
||||
try {
|
||||
await prisma.note.update({ where: { id: noteId }, data })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (msg.includes('publishedContent') || msg.includes('Unknown argument')) {
|
||||
const { publishedContent, publishedTemplate, publishedSourceHash, ...core } = data
|
||||
await prisma.note.update({ where: { id: noteId }, data: core })
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { noteId, action } = await request.json()
|
||||
const body = await request.json()
|
||||
const { noteId, action, mode, template, language, rewrite } = body as {
|
||||
noteId?: string
|
||||
action?: string
|
||||
mode?: 'simple' | 'ai'
|
||||
template?: string
|
||||
language?: string
|
||||
rewrite?: boolean
|
||||
}
|
||||
|
||||
if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 })
|
||||
|
||||
const note = await prisma.note.findFirst({
|
||||
@@ -28,14 +111,96 @@ export async function POST(request: NextRequest) {
|
||||
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
if (action === 'publish') {
|
||||
// --- AI Moderation ---
|
||||
let moderation
|
||||
try {
|
||||
moderation = await contentModerationService.moderate(note.title || '', note.content || '')
|
||||
} catch {
|
||||
moderation = { verdict: 'safe' as const, categories: ['safe'], reason: 'Moderation indisponible' }
|
||||
const publishMode = mode === 'ai' ? 'ai' : 'simple'
|
||||
|
||||
if (publishMode === 'ai') {
|
||||
if (!(await hasUserAiConsent())) {
|
||||
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
|
||||
}
|
||||
if (!template || !isPublishTemplateId(template)) {
|
||||
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
await reserveUsageOrThrow(session.user.id, 'publish_enhance')
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
const isTierLocked = err.currentQuota === 0
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isTierLocked ? 'feature_locked' : 'quota_exceeded',
|
||||
errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded',
|
||||
upgradeTier: err.upgradeTier,
|
||||
},
|
||||
{ status: 402 },
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
let renderedHtml: string
|
||||
let textForModeration: string
|
||||
|
||||
try {
|
||||
if (rewrite) {
|
||||
const spec = await publishEnhanceService.rewrite(
|
||||
note.title || '',
|
||||
note.content || '',
|
||||
template,
|
||||
language || 'fr',
|
||||
)
|
||||
renderedHtml = renderRewrittenTemplate(spec, template, note.content || '')
|
||||
textForModeration = `${spec.summary}\n${spec.body.replace(/<[^>]+>/g, ' ')}`
|
||||
} else {
|
||||
const spec = await publishEnhanceService.enhance(
|
||||
note.title || '',
|
||||
note.content || '',
|
||||
template,
|
||||
language || 'fr',
|
||||
)
|
||||
renderedHtml = renderPublishedTemplate(spec, template, note.content || '')
|
||||
textForModeration = [spec.summary, spec.pullQuote, spec.epigraph, ...(spec.keyPoints || [])].join('\n')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[publish] AI generation failed:', err)
|
||||
return NextResponse.json({ error: 'ai_generation_failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
const moderation = await moderateWithFallback(note.title || '', textForModeration)
|
||||
if (moderation.verdict === 'blocked') {
|
||||
return NextResponse.json({
|
||||
error: 'blocked',
|
||||
reason: moderation.reason,
|
||||
categories: moderation.categories,
|
||||
}, { status: 403 })
|
||||
}
|
||||
if (moderation.verdict === 'flagged') {
|
||||
await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
|
||||
}
|
||||
|
||||
const slug = await ensureSlug(note.id, note.title || '', note.publicSlug)
|
||||
const sourceHash = computePublishedSourceHash(note.content || '')
|
||||
|
||||
await updateNotePublishState(noteId, {
|
||||
isPublic: true,
|
||||
publicSlug: slug,
|
||||
publishedAt: new Date(),
|
||||
publishedContent: renderedHtml,
|
||||
publishedTemplate: template,
|
||||
publishedSourceHash: sourceHash,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
slug,
|
||||
mode: 'ai',
|
||||
template,
|
||||
moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// Simple publish — contenu brut de la note
|
||||
const moderation = await moderateWithFallback(note.title || '', note.content || '')
|
||||
if (moderation.verdict === 'blocked') {
|
||||
return NextResponse.json({
|
||||
error: 'blocked',
|
||||
@@ -43,46 +208,36 @@ export async function POST(request: NextRequest) {
|
||||
categories: moderation.categories,
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
// flagged → publish but notify admins
|
||||
if (moderation.verdict === 'flagged') {
|
||||
const admins = await prisma.user.findMany({ where: { role: 'ADMIN' }, select: { id: true } })
|
||||
for (const admin of admins) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
type: 'content_flagged',
|
||||
title: 'Contenu sensible publié',
|
||||
message: `La note "${note.title}" a été publiée avec un contenu potentiellement sensible: ${moderation.reason}`,
|
||||
actionUrl: '/admin/published',
|
||||
relatedId: note.id,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
|
||||
}
|
||||
|
||||
let slug = note.publicSlug
|
||||
if (!slug) {
|
||||
slug = generateSlug(note.title || 'note')
|
||||
const existing = await prisma.note.findUnique({ where: { publicSlug: slug } })
|
||||
if (existing && existing.id !== noteId) slug = `${slug}-${Date.now().toString(36)}`
|
||||
}
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { isPublic: true, publicSlug: slug, publishedAt: new Date() },
|
||||
const slug = await ensureSlug(note.id, note.title || '', note.publicSlug)
|
||||
await updateNotePublishState(noteId, {
|
||||
isPublic: true,
|
||||
publicSlug: slug,
|
||||
publishedAt: new Date(),
|
||||
publishedContent: null,
|
||||
publishedTemplate: null,
|
||||
publishedSourceHash: null,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
slug,
|
||||
mode: 'simple',
|
||||
moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'unpublish') {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { isPublic: false, publicSlug: null, publishedAt: null },
|
||||
await updateNotePublishState(noteId, {
|
||||
isPublic: false,
|
||||
publicSlug: null,
|
||||
publishedAt: null,
|
||||
publishedContent: null,
|
||||
publishedTemplate: null,
|
||||
publishedSourceHash: null,
|
||||
})
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const DEMO_NOTES: Record<string, DemoNote[]> = {
|
||||
<li><strong>Designing Data-Intensive Applications</strong> — Martin Kleppmann · Architecture systèmes</li>
|
||||
<li><strong>The Pragmatic Programmer</strong> — Hunt & Thomas · Bonnes pratiques développement</li>
|
||||
</ul>
|
||||
<p>Prochaine lecture : Building a Second Brain (lien avec Momento évident).</p>`,
|
||||
<p>Prochaine lecture : Building a Second Brain (lien avec Memento évident).</p>`,
|
||||
},
|
||||
{
|
||||
title: 'Notes de formation React — Hooks avancés',
|
||||
@@ -132,7 +132,7 @@ const DEMO_NOTES: Record<string, DemoNote[]> = {
|
||||
<li><strong>Designing Data-Intensive Applications</strong> — Martin Kleppmann · Systems architecture</li>
|
||||
<li><strong>The Pragmatic Programmer</strong> — Hunt & Thomas · Development best practices</li>
|
||||
</ul>
|
||||
<p>Next read: Building a Second Brain (obvious connection to Momento).</p>`,
|
||||
<p>Next read: Building a Second Brain (obvious connection to Memento).</p>`,
|
||||
},
|
||||
{
|
||||
title: 'React Training Notes — Advanced Hooks',
|
||||
|
||||
@@ -375,7 +375,7 @@ export async function GET() {
|
||||
<body>
|
||||
<div id="sidebar">
|
||||
<div id="sidebar-header">
|
||||
<h1>Momento</h1>
|
||||
<h1>Memento</h1>
|
||||
<p>Offline Workspace Export</p>
|
||||
</div>
|
||||
<div id="search-bar">
|
||||
|
||||
@@ -419,7 +419,7 @@ export function ByokSettingsPanel() {
|
||||
{activeKey ? (
|
||||
<><Zap size={14} className="shrink-0" /><span>BYOK actif · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
|
||||
) : (
|
||||
<><Shield size={14} className="shrink-0" /><span>Aucune clé active — utilisation des quotas Momento</span></>
|
||||
<><Shield size={14} className="shrink-0" /><span>Aucune clé active — utilisation des quotas Memento</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function AutoLabelSuggestionDialog({
|
||||
setSuggestions(data.data)
|
||||
const allLabelNames = new Set<string>(data.data.suggestedLabels.map((l: SuggestedLabel) => l.name as string))
|
||||
setSelectedLabels(allLabelNames)
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
} else {
|
||||
if (data.message) {
|
||||
toast.info(data.message)
|
||||
|
||||
@@ -275,6 +275,13 @@ export function ContextualAIChat({
|
||||
const lastMsgHasContent = lastMsg?.role === 'assistant' && !!getMessageContent(lastMsg)
|
||||
const isLoading = (status === 'submitted' || status === 'streaming') && !lastMsgHasContent
|
||||
|
||||
// Dispatch quota refresh quand le streaming se termine
|
||||
useEffect(() => {
|
||||
if (status === 'ready' && messages.length > 0) {
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
}
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, resourcePreview])
|
||||
@@ -1283,7 +1290,7 @@ export function ContextualAIChat({
|
||||
<div className="flex flex-col items-center gap-4 py-8 opacity-20 mt-auto shrink-0 border-t border-border/10">
|
||||
<PenTool size={20} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.3em] whitespace-nowrap italic text-center">
|
||||
{t('nav.workspace') || 'Momento Workspace'}
|
||||
{t('nav.workspace') || 'Memento Workspace'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload } from 'lucide-react'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe } from 'lucide-react'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
@@ -58,10 +58,18 @@ const OrganizeNotebookDialog = dynamic(
|
||||
() => import('@/components/organize-notebook-dialog').then(m => ({ default: m.OrganizeNotebookDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const NotebookSiteDialog = dynamic(
|
||||
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsIntro = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsWizard = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-wizard').then(m => ({ default: m.StructuredViewsWizard })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const StructuredViewsHelpBanner = dynamic(
|
||||
() => import('@/components/structured-views/structured-views-help-banner').then(m => ({ default: m.StructuredViewsHelpBanner })),
|
||||
{ ssr: false }
|
||||
@@ -95,7 +103,7 @@ export function HomeClient({
|
||||
}: HomeClientProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
|
||||
const [notes, setNotes] = useState<Note[]>(initialNotes)
|
||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
|
||||
@@ -136,6 +144,10 @@ export function HomeClient({
|
||||
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
|
||||
const [addPropertyOpen, setAddPropertyOpen] = useState(false)
|
||||
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
|
||||
const [showStructuredWizard, setShowStructuredWizard] = useState(false)
|
||||
const [showNotebookSite, setShowNotebookSite] = useState(false)
|
||||
const [aiMenuOpen, setAiMenuOpen] = useState(false)
|
||||
const aiMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
||||
const [showOrganizer, setShowOrganizer] = useState(false)
|
||||
|
||||
@@ -237,7 +249,7 @@ export function HomeClient({
|
||||
}
|
||||
|
||||
let allNotes = search
|
||||
? await searchNotes(search, true, notebook || undefined)
|
||||
? await searchNotes(search, true, notebook || undefined).then(r => { window.dispatchEvent(new Event('ai-usage-changed')); return r })
|
||||
: await getAllNotes(false, notebook || undefined)
|
||||
|
||||
if (sharedOnly) {
|
||||
@@ -1045,31 +1057,65 @@ export function HomeClient({
|
||||
)}
|
||||
|
||||
{searchParams.get('notebook') && initialSettings.aiAssistantEnabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative" ref={aiMenuRef}>
|
||||
<button
|
||||
onClick={() => setSummaryDialogOpen(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setAiMenuOpen(o => !o)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[12px] font-medium transition-colors px-2.5 py-1.5 rounded-lg border',
|
||||
aiMenuOpen
|
||||
? 'bg-brand-accent/10 border-brand-accent/30 text-brand-accent'
|
||||
: 'border-border/50 text-muted-foreground hover:text-foreground hover:border-border',
|
||||
)}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>{t('notebook.summary')}</span>
|
||||
</button>
|
||||
<span className="w-px h-3.5 bg-border/40" />
|
||||
<button
|
||||
onClick={() => setShowStudyPlanner(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-brand-accent transition-colors"
|
||||
>
|
||||
<CalendarDays size={14} />
|
||||
<span>{t('wizard.studyPlanner') || 'Planning'}</span>
|
||||
</button>
|
||||
<span className="w-px h-3.5 bg-border/40" />
|
||||
<button
|
||||
onClick={() => setOrganizeNotebookOpen(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-brand-accent transition-colors"
|
||||
title={t('notebook.organizeNotebookWithAITooltip')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>{t('batch.organize')}</span>
|
||||
<Sparkles size={13} />
|
||||
<span>IA</span>
|
||||
<ChevronDown size={11} className={cn('transition-transform', aiMenuOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{aiMenuOpen && (
|
||||
<>
|
||||
{/* overlay invisible pour fermer au clic extérieur */}
|
||||
<div className="fixed inset-0 z-10" onClick={() => setAiMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1.5 z-20 w-52 bg-popover border border-border rounded-xl shadow-lg overflow-hidden">
|
||||
{[
|
||||
{
|
||||
icon: <FileText size={14} />,
|
||||
label: t('notebook.summary') || 'Résumé du carnet',
|
||||
action: () => { setSummaryDialogOpen(true); setAiMenuOpen(false) },
|
||||
},
|
||||
{
|
||||
icon: <CalendarDays size={14} />,
|
||||
label: t('wizard.studyPlanner') || 'Planning de révision',
|
||||
action: () => { setShowStudyPlanner(true); setAiMenuOpen(false) },
|
||||
},
|
||||
{
|
||||
icon: <Sparkles size={14} />,
|
||||
label: t('batch.organize') || 'Organiser avec l\'IA',
|
||||
action: () => { setOrganizeNotebookOpen(true); setAiMenuOpen(false) },
|
||||
},
|
||||
{
|
||||
icon: <TagIcon size={14} />,
|
||||
label: t('wizard.autoTags') || 'Tags automatiques',
|
||||
action: () => { setShowOrganizer(true); setAiMenuOpen(false) },
|
||||
},
|
||||
{
|
||||
icon: <Globe size={14} />,
|
||||
label: t('notebookSite.shortTitle') || 'Site web',
|
||||
action: () => { setShowNotebookSite(true); setAiMenuOpen(false) },
|
||||
},
|
||||
].map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={item.action}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-[12px] text-foreground hover:bg-muted/60 transition-colors text-left"
|
||||
>
|
||||
<span className="text-muted-foreground">{item.icon}</span>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{searchParams.get('notebook') && (
|
||||
@@ -1185,11 +1231,29 @@ export function HomeClient({
|
||||
) : showStructuredLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
|
||||
) : showStructuredIntro ? (
|
||||
<StructuredViewsIntro
|
||||
target={structuredViewMode}
|
||||
enabling={isEnablingStructured}
|
||||
onEnable={() => void handleEnableStructured(structuredViewMode)}
|
||||
/>
|
||||
showStructuredWizard ? (
|
||||
<StructuredViewsWizard
|
||||
open={showStructuredWizard}
|
||||
onClose={() => setShowStructuredWizard(false)}
|
||||
onComplete={(view) => {
|
||||
setShowStructuredWizard(false)
|
||||
if (view !== 'gallery') setLayoutMode(view)
|
||||
void schemaHook.reload()
|
||||
}}
|
||||
structuredModeActive={structuredModeActive}
|
||||
enableStructuredMode={schemaHook.enableStructuredMode}
|
||||
addProperty={schemaHook.addProperty}
|
||||
setKanbanGroupProperty={schemaHook.setKanbanGroupProperty}
|
||||
initialGoal={structuredViewMode === 'kanban' ? 'tasks' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<StructuredViewsIntro
|
||||
target={structuredViewMode}
|
||||
enabling={isEnablingStructured}
|
||||
onEnable={() => void handleEnableStructured(structuredViewMode)}
|
||||
onOpenWizard={() => setShowStructuredWizard(true)}
|
||||
/>
|
||||
)
|
||||
) : showStructuredDataView && schemaHook.schema && notebookFilter ? (
|
||||
<>
|
||||
<StructuredViewsHelpBanner notebookId={notebookFilter} mode={structuredViewMode} />
|
||||
@@ -1347,6 +1411,14 @@ export function HomeClient({
|
||||
onClose={() => setShowOrganizer(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showNotebookSite && currentNotebook && (
|
||||
<NotebookSiteDialog
|
||||
notebookId={currentNotebook.id}
|
||||
notebookName={currentNotebook.name}
|
||||
onClose={() => setShowNotebookSite(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function LandingPage() {
|
||||
<div className="w-10 h-10 bg-ink flex items-center justify-center rounded-xl shadow-lg rotate-3 group hover:rotate-0 transition-transform cursor-pointer">
|
||||
<span className="text-paper font-serif text-2xl font-bold">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Momento</span>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Memento</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-10">
|
||||
@@ -104,7 +104,7 @@ export function LandingPage() {
|
||||
{/* App Preview Mockup */}
|
||||
<motion.div initial={{ opacity: 0, y: 100 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1, delay: 0.2, ease: [0.23, 1, 0.32, 1] }} className="mt-24 relative">
|
||||
<div className="relative mx-auto max-w-5xl aspect-[16/10] bg-white rounded-[32px] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.15)] border border-border p-4 overflow-hidden group">
|
||||
<img src="/images/workspace-hero.jpg" alt="Momento Workspace" className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" />
|
||||
<img src="/images/workspace-hero.jpg" alt="Memento Workspace" className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" />
|
||||
<div className="absolute inset-0 bg-ink/10 group-hover:bg-ink/0 transition-colors duration-500" />
|
||||
|
||||
<div className="absolute top-10 right-10 w-64 bg-paper/90 backdrop-blur-xl border border-border p-6 rounded-2xl shadow-2xl">
|
||||
@@ -381,7 +381,7 @@ export function LandingPage() {
|
||||
<div className="w-8 h-8 bg-ink flex items-center justify-center rounded-lg">
|
||||
<span className="text-paper font-serif text-lg font-bold">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Momento</span>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
</div>
|
||||
<p className="text-sm text-concrete font-light max-w-xs">{t('landing.footer.desc')}</p>
|
||||
</div>
|
||||
|
||||
@@ -839,11 +839,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}, [isDirty, isSaving, readOnly, fullPage, autoSaveEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullPage) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
void handleSaveInPlaceRef.current()
|
||||
if (fullPage) {
|
||||
void handleSaveInPlaceRef.current()
|
||||
} else {
|
||||
void handleSaveRef.current()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useNoteEditorContext } from './note-editor-context'
|
||||
import { LabelManager } from '@/components/label-manager'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
@@ -19,11 +19,10 @@ import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
|
||||
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe
|
||||
Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe, ExternalLink
|
||||
} from 'lucide-react'
|
||||
import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
import { PublishDialog } from './publish-dialog'
|
||||
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -33,6 +32,10 @@ import { useVoiceTranscription } from '@/hooks/use-voice-transcription'
|
||||
import { toast } from 'sonner'
|
||||
import { format } from 'date-fns'
|
||||
import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export'
|
||||
import { getCalloutColors } from '@/lib/editor/callout-colors'
|
||||
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { PUBLISH_TEMPLATES, type PublishTemplateId } from '@/lib/publish/types'
|
||||
|
||||
interface NoteEditorToolbarProps {
|
||||
mode: 'fullPage' | 'dialog'
|
||||
@@ -44,12 +47,64 @@ interface NoteEditorToolbarProps {
|
||||
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext()
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [shareOpen, setShareOpen] = useState(false)
|
||||
const [flashcardsOpen, setFlashcardsOpen] = useState(false)
|
||||
const [publishOpen, setPublishOpen] = useState(false)
|
||||
const [publishLoading, setPublishLoading] = useState(false)
|
||||
const [publishMeta, setPublishMeta] = useState({
|
||||
isPublic: Boolean(note.isPublic),
|
||||
slug: note.publicSlug ?? null,
|
||||
template: (note.publishedTemplate as PublishTemplateId | null) ?? null,
|
||||
})
|
||||
const [publishLinkCopied, setPublishLinkCopied] = useState(false)
|
||||
const [publishTemplate, setPublishTemplate] = useState<PublishTemplateId>('magazine')
|
||||
const [publishRewrite, setPublishRewrite] = useState(false)
|
||||
const [publishEnhanceRemaining, setPublishEnhanceRemaining] = useState<number | null>(null)
|
||||
const [publishEnhanceLocked, setPublishEnhanceLocked] = useState(false)
|
||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||
|
||||
useEffect(() => {
|
||||
setPublishMeta({
|
||||
isPublic: Boolean(note.isPublic),
|
||||
slug: note.publicSlug ?? null,
|
||||
template: (note.publishedTemplate as PublishTemplateId | null) ?? null,
|
||||
})
|
||||
}, [note.id, note.isPublic, note.publicSlug, note.publishedTemplate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!publishOpen) return
|
||||
|
||||
const loadPublishQuota = () => {
|
||||
void fetch('/api/usage/current')
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => {
|
||||
const q = data?.quotas?.publish_enhance
|
||||
if (q === undefined) {
|
||||
setPublishEnhanceLocked(false)
|
||||
setPublishEnhanceRemaining(null)
|
||||
return
|
||||
}
|
||||
if (q.limit === 0) {
|
||||
setPublishEnhanceLocked(true)
|
||||
setPublishEnhanceRemaining(0)
|
||||
return
|
||||
}
|
||||
setPublishEnhanceLocked(false)
|
||||
setPublishEnhanceRemaining(q.remaining ?? 0)
|
||||
})
|
||||
.catch(() => {
|
||||
setPublishEnhanceLocked(false)
|
||||
setPublishEnhanceRemaining(null)
|
||||
})
|
||||
}
|
||||
|
||||
loadPublishQuota()
|
||||
window.addEventListener('ai-usage-changed', loadPublishQuota)
|
||||
return () => window.removeEventListener('ai-usage-changed', loadPublishQuota)
|
||||
}, [publishOpen])
|
||||
|
||||
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
|
||||
|
||||
// ── Voice transcription ──────────────────────────────────────────────────
|
||||
@@ -137,18 +192,11 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
// Apply callout colors as inline styles (Tailwind classes won't work in print window)
|
||||
clone.querySelectorAll('[data-callout-type]').forEach(el => {
|
||||
const type = el.getAttribute('data-callout-type')
|
||||
const colors: Record<string, { bg: string; border: string }> = {
|
||||
info: { bg: '#eff6ff', border: '#93c5fd' },
|
||||
warning: { bg: '#fffbeb', border: '#fcd34d' },
|
||||
tip: { bg: '#faf5ff', border: '#c4b5fd' },
|
||||
success: { bg: '#f0fdf4', border: '#86efac' },
|
||||
danger: { bg: '#fef2f2', border: '#fca5a5' },
|
||||
}
|
||||
const c = colors[type || 'info'] || colors.info
|
||||
const inner = el.querySelector('div')
|
||||
const { bg, border } = getCalloutColors(type)
|
||||
const inner = el.querySelector('div') as HTMLElement | null
|
||||
if (inner) {
|
||||
(inner as HTMLElement).style.background = c.bg
|
||||
(inner as HTMLElement).style.borderColor = c.border
|
||||
inner.style.background = bg
|
||||
inner.style.borderColor = border
|
||||
}
|
||||
})
|
||||
|
||||
@@ -239,8 +287,176 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
|
||||
const [generatingExercises, setGeneratingExercises] = useState(false)
|
||||
const [showEduMenu, setShowEduMenu] = useState(false)
|
||||
|
||||
const publicPageUrl = publishMeta.slug
|
||||
? `${typeof window !== 'undefined' ? window.location.origin : ''}/p/${publishMeta.slug}`
|
||||
: ''
|
||||
|
||||
const handlePublishNote = async () => {
|
||||
if (publishLoading) return
|
||||
if (state.isDirty && !state.isSaving) {
|
||||
await actions.handleSaveInPlace()
|
||||
}
|
||||
setPublishLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/notes/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ noteId: note.id, action: 'publish', mode: 'simple' }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.slug) {
|
||||
setPublishMeta({ isPublic: true, slug: data.slug, template: null })
|
||||
emitNoteChange({
|
||||
type: 'updated',
|
||||
note: { ...note, isPublic: true, publicSlug: data.slug, publishedTemplate: null },
|
||||
})
|
||||
const url = `${window.location.origin}/p/${data.slug}`
|
||||
toast.success(t('richTextEditor.publishSuccess'), {
|
||||
description: url,
|
||||
action: {
|
||||
label: t('richTextEditor.publishLive') || 'Voir',
|
||||
onClick: () => { window.open(url, '_blank', 'noopener,noreferrer') },
|
||||
},
|
||||
duration: 8000,
|
||||
})
|
||||
setPublishOpen(false)
|
||||
} else if (data.error === 'blocked') {
|
||||
toast.error(t('richTextEditor.publishBlocked'), {
|
||||
description: data.reason || undefined,
|
||||
duration: 6000,
|
||||
})
|
||||
} else {
|
||||
toast.error(data.error || t('general.error'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
} finally {
|
||||
setPublishLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const publishTemplateLabels: Record<PublishTemplateId, string> = {
|
||||
magazine: t('richTextEditor.publishTemplateMagazine'),
|
||||
brief: t('richTextEditor.publishTemplateBrief'),
|
||||
essay: t('richTextEditor.publishTemplateEssay'),
|
||||
}
|
||||
|
||||
const handlePublishWithAi = async () => {
|
||||
if (publishLoading || publishEnhanceLocked || (publishEnhanceRemaining !== null && publishEnhanceRemaining <= 0)) return
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
if (state.isDirty && !state.isSaving) {
|
||||
await actions.handleSaveInPlace()
|
||||
}
|
||||
setPublishLoading(true)
|
||||
const toastId = toast.loading(t('richTextEditor.publishWithAiGenerating'))
|
||||
try {
|
||||
const res = await fetch('/api/notes/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
noteId: note.id,
|
||||
action: 'publish',
|
||||
mode: 'ai',
|
||||
template: publishTemplate,
|
||||
rewrite: publishRewrite,
|
||||
language,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.status === 402) {
|
||||
toast.dismiss(toastId)
|
||||
toast.error(
|
||||
data.errorKey === 'ai.featureLocked'
|
||||
? t('richTextEditor.publishWithAiLocked')
|
||||
: t('ai.quotaExceeded'),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (res.ok && data.slug) {
|
||||
setPublishMeta({ isPublic: true, slug: data.slug, template: publishTemplate })
|
||||
emitNoteChange({
|
||||
type: 'updated',
|
||||
note: {
|
||||
...note,
|
||||
isPublic: true,
|
||||
publicSlug: data.slug,
|
||||
publishedTemplate: publishTemplate,
|
||||
},
|
||||
})
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
const url = `${window.location.origin}/p/${data.slug}`
|
||||
toast.success(t('richTextEditor.publishAiSuccess'), {
|
||||
id: toastId,
|
||||
description: url,
|
||||
action: {
|
||||
label: t('richTextEditor.publishLive'),
|
||||
onClick: () => { window.open(url, '_blank', 'noopener,noreferrer') },
|
||||
},
|
||||
duration: 8000,
|
||||
})
|
||||
setPublishOpen(false)
|
||||
} else if (data.error === 'blocked') {
|
||||
toast.dismiss(toastId)
|
||||
toast.error(t('richTextEditor.publishBlocked'), {
|
||||
description: data.reason || undefined,
|
||||
duration: 6000,
|
||||
})
|
||||
} else {
|
||||
toast.dismiss(toastId)
|
||||
toast.error(data.error || t('general.error'))
|
||||
}
|
||||
} catch {
|
||||
toast.dismiss(toastId)
|
||||
toast.error(t('general.error'))
|
||||
} finally {
|
||||
setPublishLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublishNote = async () => {
|
||||
if (publishLoading) return
|
||||
setPublishLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/notes/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ noteId: note.id, action: 'unpublish' }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setPublishMeta({ isPublic: false, slug: null, template: null })
|
||||
emitNoteChange({
|
||||
type: 'updated',
|
||||
note: { ...note, isPublic: false, publicSlug: null, publishedTemplate: null },
|
||||
})
|
||||
toast.success(t('richTextEditor.unpublishSuccess'))
|
||||
setPublishOpen(false)
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
toast.error(data.error || t('general.error'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
} finally {
|
||||
setPublishLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyPublicLink = async () => {
|
||||
if (!publicPageUrl) return
|
||||
const ok = await copyTextToClipboard(publicPageUrl)
|
||||
if (ok) {
|
||||
setPublishLinkCopied(true)
|
||||
setTimeout(() => setPublishLinkCopied(false), 2000)
|
||||
toast.success(t('richTextEditor.copyPublicLink') || 'Lien copié')
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateExercises = async () => {
|
||||
if (generatingExercises) return
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
setGeneratingExercises(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/generate-exercises', {
|
||||
@@ -252,13 +468,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
if (!res.ok) {
|
||||
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
||||
} else {
|
||||
toast.success(`${data.exercises?.length || 0} ${t('richTextEditor.exercisesGenerated') || 'exercices créés dans ce carnet !'}`, {
|
||||
action: {
|
||||
label: t('richTextEditor.seeExercises') || 'Voir',
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
})
|
||||
// Emit events so the note list refreshes
|
||||
toast.success(`${data.exercises?.length || 0} ${t('richTextEditor.exercisesGenerated') || 'exercices créés !'}`)
|
||||
for (const ex of data.exercises || []) {
|
||||
emitNoteChange({ type: 'created', note: { ...note, id: ex.id, title: ex.title, content: '<p></p>' } as any })
|
||||
}
|
||||
@@ -499,18 +709,191 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<button
|
||||
title={t('richTextEditor.publishTitle') || 'Publication publique'}
|
||||
onClick={() => setPublishOpen(true)}
|
||||
className={cn(
|
||||
"p-1.5 rounded-full border transition-all",
|
||||
note.isPublic
|
||||
? "border-green-400/40 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950/20"
|
||||
: "border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Globe size={16} />
|
||||
</button>
|
||||
<DropdownMenu open={publishOpen} onOpenChange={setPublishOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={t('richTextEditor.publishTitle')}
|
||||
aria-label={t('richTextEditor.publishTitle')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full border transition-all',
|
||||
publishMeta.isPublic
|
||||
? 'border-green-400/40 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950/20'
|
||||
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<Globe size={16} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 rounded-xl p-4 shadow-xl">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center shrink-0">
|
||||
<Globe size={13} className="text-brand-accent" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{t('richTextEditor.publishTitle')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
||||
{t('richTextEditor.publishDesc')}
|
||||
</p>
|
||||
{publishMeta.isPublic && publishMeta.slug ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/50">
|
||||
<Check size={14} className="text-green-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">
|
||||
{t('richTextEditor.publishLive')}
|
||||
{publishMeta.template && (
|
||||
<span className="text-green-600/80 dark:text-green-400/80">
|
||||
{' · '}{publishTemplateLabels[publishMeta.template]}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 p-2 rounded-lg border border-border bg-muted/40">
|
||||
<span className="flex-1 text-xs text-muted-foreground truncate px-1">{publicPageUrl}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyPublicLink}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
aria-label={t('richTextEditor.copyPublicLink')}
|
||||
>
|
||||
{publishLinkCopied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
<a
|
||||
href={publicPageUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
aria-label={t('richTextEditor.openPublicPage')}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUnpublishNote}
|
||||
disabled={publishLoading}
|
||||
className="w-full py-2 rounded-lg border border-red-200 dark:border-red-800/50 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{publishLoading ? <Loader2 size={14} className="animate-spin mx-auto" /> : t('richTextEditor.unpublish')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublishNote}
|
||||
disabled={publishLoading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg border border-border bg-background text-sm font-medium hover:bg-muted disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{publishLoading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
|
||||
{t('richTextEditor.publishSimple')}
|
||||
</button>
|
||||
<p className="text-[10px] text-muted-foreground text-center -mt-1">
|
||||
{t('richTextEditor.publishSimpleHint')}
|
||||
</p>
|
||||
|
||||
<div className="border-t border-border/50 pt-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles size={13} className="text-brand-accent shrink-0" />
|
||||
<span className="text-xs font-semibold">{t('richTextEditor.publishWithAi')}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{publishEnhanceLocked
|
||||
? t('richTextEditor.publishWithAiLocked')
|
||||
: t('richTextEditor.publishWithAiHint').replace(
|
||||
'{count}',
|
||||
String(publishEnhanceRemaining ?? '…'),
|
||||
)}
|
||||
</p>
|
||||
{/* Sélection template */}
|
||||
<div className="space-y-1">
|
||||
{PUBLISH_TEMPLATES.map((tpl) => (
|
||||
<label
|
||||
key={tpl}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer text-xs transition-colors',
|
||||
publishTemplate === tpl
|
||||
? 'bg-brand-accent/10 text-brand-accent font-medium'
|
||||
: 'hover:bg-muted text-foreground',
|
||||
(publishEnhanceLocked || publishLoading) && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="publish-template"
|
||||
value={tpl}
|
||||
checked={publishTemplate === tpl}
|
||||
onChange={() => setPublishTemplate(tpl)}
|
||||
className="accent-[var(--color-brand-accent)]"
|
||||
/>
|
||||
{publishTemplateLabels[tpl]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toggle reformulation */}
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border p-3 transition-colors',
|
||||
publishRewrite
|
||||
? 'border-brand-accent/40 bg-brand-accent/5'
|
||||
: 'border-border bg-muted/30',
|
||||
(publishEnhanceLocked || publishLoading) && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<div className="relative mt-0.5 shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={publishRewrite}
|
||||
onChange={e => setPublishRewrite(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={cn(
|
||||
'w-8 h-4 rounded-full transition-colors',
|
||||
publishRewrite ? 'bg-brand-accent' : 'bg-muted-foreground/30',
|
||||
)}>
|
||||
<div className={cn(
|
||||
'w-3 h-3 rounded-full bg-white shadow transition-transform mt-0.5',
|
||||
publishRewrite ? 'translate-x-4' : 'translate-x-0.5',
|
||||
)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold leading-tight">
|
||||
{t('richTextEditor.publishRewriteLabel')}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">
|
||||
{publishRewrite
|
||||
? t('richTextEditor.publishRewriteOnHint')
|
||||
: t('richTextEditor.publishRewriteOffHint')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublishWithAi}
|
||||
disabled={
|
||||
publishLoading
|
||||
|| publishEnhanceLocked
|
||||
|| (publishEnhanceRemaining !== null && publishEnhanceRemaining <= 0)
|
||||
}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-brand-accent text-white text-sm font-medium hover:bg-brand-accent/90 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{publishLoading ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Sparkles size={14} />
|
||||
)}
|
||||
{t('richTextEditor.publishWithAi')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
@@ -752,16 +1135,6 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{publishOpen && (
|
||||
<PublishDialog
|
||||
open={publishOpen}
|
||||
onClose={() => setPublishOpen(false)}
|
||||
noteId={note.id}
|
||||
noteTitle={state.title || note.title || 'Untitled'}
|
||||
isPublic={note.isPublic}
|
||||
publicSlug={note.publicSlug ?? null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Globe, X, Copy, Check, Loader2, ExternalLink } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard'
|
||||
|
||||
interface PublishDialogProps {
|
||||
@@ -25,8 +25,6 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
|
||||
|
||||
useEffect(() => { setIsPublic(initialPublic); setSlug(publicSlug) }, [initialPublic, publicSlug])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const publicUrl = slug ? `${window.location.origin}/p/${slug}` : ''
|
||||
|
||||
const handlePublish = async () => {
|
||||
@@ -41,16 +39,10 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
|
||||
if (res.ok && data.slug) {
|
||||
setIsPublic(true)
|
||||
setSlug(data.slug)
|
||||
if (data.moderation === 'flagged') {
|
||||
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !', {
|
||||
description: '⚠️ Un modérateur examinera le contenu sous peu.',
|
||||
})
|
||||
} else {
|
||||
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !')
|
||||
}
|
||||
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !')
|
||||
} else if (data.error === 'blocked') {
|
||||
toast.error(t('richTextEditor.publishBlocked') || 'Publication refusée', {
|
||||
description: data.reason || 'Le contenu ne respecte pas les règles de publication.',
|
||||
description: data.reason || 'Le contenu ne respecte pas les règles.',
|
||||
duration: 6000,
|
||||
})
|
||||
} else {
|
||||
@@ -82,57 +74,70 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
|
||||
if (ok) { setCopied(true); setTimeout(() => setCopied(false), 2000); toast.success('Lien copié !') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" dir="auto" onClick={onClose}>
|
||||
<div className="w-full max-w-md max-h-[90vh] overflow-y-auto rounded-2xl border border-border bg-card shadow-2xl p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden border border-black/10 dark:border-white/10"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-brand-accent/10 flex items-center justify-center">
|
||||
<Globe size={15} className="text-brand-accent" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold">{t('richTextEditor.publishTitle') || 'Publication publique'}</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{t('richTextEditor.publishTitle') || 'Publication publique'}</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted text-muted-foreground"><X size={16} /></button>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-400">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mb-4 leading-relaxed">
|
||||
{t('richTextEditor.publishDesc') || 'Publiez cette note sur une URL publique. Tout le monde avec le lien pourra la lire.'}
|
||||
</p>
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4 leading-relaxed">
|
||||
{t('richTextEditor.publishDesc') || 'Publiez cette note sur une URL publique. Tout le monde avec le lien pourra la lire.'}
|
||||
</p>
|
||||
|
||||
{isPublic && slug ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/50">
|
||||
<Check size={14} className="text-green-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">{t('richTextEditor.publishLive') || 'En ligne'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 p-2 rounded-xl border border-border bg-background">
|
||||
<span className="flex-1 text-xs text-muted-foreground truncate px-1">{publicUrl}</span>
|
||||
<button onClick={copyLink} className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0">
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
{isPublic && slug ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800/50">
|
||||
<Check size={14} className="text-green-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-green-700 dark:text-green-400">{t('richTextEditor.publishLive') || 'En ligne'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 p-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800">
|
||||
<span className="flex-1 text-xs text-gray-500 dark:text-gray-400 truncate px-1">{publicUrl}</span>
|
||||
<button onClick={copyLink} className="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors shrink-0">
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
<a href={publicUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 rounded-md hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors shrink-0">
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUnpublish}
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded-xl border border-red-200 dark:border-red-800/50 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin mx-auto" /> : (t('richTextEditor.unpublish') || 'Dépublier')}
|
||||
</button>
|
||||
<a href={publicUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors shrink-0">
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleUnpublish}
|
||||
onClick={handlePublish}
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded-xl border border-red-200 dark:border-red-800/50 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 transition-colors disabled:opacity-40"
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-medium hover:bg-brand-accent/90 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin mx-auto" /> : (t('richTextEditor.unpublish') || 'Dépublier')}
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
|
||||
{t('richTextEditor.publish') || 'Publier'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-medium hover:bg-brand-accent/90 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <Globe size={14} />}
|
||||
{t('richTextEditor.publish') || 'Publier'}
|
||||
</button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function OrganizeNotebookDialog({
|
||||
|
||||
const handleAnalyze = useCallback(async () => {
|
||||
setStep('analyzing'); setError(null); setPlan(null)
|
||||
const res = await analyzeNotebookForOrganization(notebookId)
|
||||
const res = await analyzeNotebookForOrganization(notebookId, language)
|
||||
if (!res.success || !res.plan) { setError(res.error ?? 'Erreur'); setStep('idle'); return }
|
||||
setPlan(res.plan)
|
||||
setEditableGroups(res.plan.groups.map(g => ({ ...g, notes: [...g.notes] })))
|
||||
|
||||
@@ -695,7 +695,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-wider text-concrete/50">
|
||||
<Command size={10} />
|
||||
<span>Momento Search</span>
|
||||
<span>Memento Search</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -209,7 +209,7 @@ export function BillingPlans() {
|
||||
name: t('billing.freePlan'),
|
||||
price: t('billing.freePrice') || 'Gratuit',
|
||||
period: '',
|
||||
description: t('billing.freeDescription') || 'Pour découvrir la magie de Momento.',
|
||||
description: t('billing.freeDescription') || 'Pour découvrir la magie de Memento.',
|
||||
features: [
|
||||
t('billing.freeF1') || '100 Notes max',
|
||||
t('billing.freeF2') || '3 Carnets',
|
||||
|
||||
@@ -46,6 +46,7 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
||||
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
|
||||
publish_enhance: t('usageMeter.featurePublishEnhance') || 'Publication IA',
|
||||
};
|
||||
|
||||
const currentFeatureLabel = featureLabels[feature] || feature;
|
||||
|
||||
@@ -20,6 +20,7 @@ const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||
aiTranscribe: 'sidebar.aiTranscribe',
|
||||
aiDiagram: 'sidebar.aiDiagram',
|
||||
aiAgent: 'sidebar.aiAgent',
|
||||
publish_enhance: 'usageMeter.featurePublishEnhance',
|
||||
};
|
||||
|
||||
function UsageBar({ used, limit, isUnlimited }: { used: number; limit: number; isUnlimited: boolean }) {
|
||||
|
||||
@@ -398,25 +398,27 @@ function SidebarCarnetItem({
|
||||
<div className="absolute start-[8px] top-1/2 w-[8px] h-px bg-border/40" />
|
||||
)}
|
||||
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="absolute start-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
>
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-0.5">
|
||||
{/* Drag handle — visible au hover, dans son propre slot avant le chevron */}
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/0 group-hover:text-muted-foreground/50 hover:!text-muted-foreground cursor-grab active:cursor-grabbing transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical size={11} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand() }}
|
||||
className="p-1 hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
|
||||
className="shrink-0 w-5 h-5 flex items-center justify-center hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
|
||||
>
|
||||
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
||||
<ChevronRight size={14} className="rtl:scale-x-[-1]" />
|
||||
<ChevronRight size={13} className="rtl:scale-x-[-1]" />
|
||||
</motion.div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6" />
|
||||
<div className="w-5" />
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
@@ -1704,9 +1706,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{showAiWizard && (
|
||||
<AiNotebookWizard
|
||||
onClose={() => setShowAiWizard(false)}
|
||||
onComplete={() => {
|
||||
onComplete={(notebookId: string) => {
|
||||
setShowAiWizard(false)
|
||||
window.location.reload()
|
||||
router.push(`/home?notebook=${notebookId}`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Columns3, Database, Table2, type LucideIcon } from 'lucide-react'
|
||||
import { Columns3, Database, Table2, Wand2, type LucideIcon } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BootstrapStructuredTarget } from '@/lib/structured-views/bootstrap-structured-notebook'
|
||||
@@ -9,9 +9,10 @@ type StructuredViewsIntroProps = {
|
||||
target: BootstrapStructuredTarget
|
||||
enabling?: boolean
|
||||
onEnable: () => void
|
||||
onOpenWizard?: () => void
|
||||
}
|
||||
|
||||
export function StructuredViewsIntro({ target, enabling, onEnable }: StructuredViewsIntroProps) {
|
||||
export function StructuredViewsIntro({ target, enabling, onEnable, onOpenWizard }: StructuredViewsIntroProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
@@ -47,21 +48,33 @@ export function StructuredViewsIntro({ target, enabling, onEnable }: StructuredV
|
||||
? t('structuredViews.intro.activateKanbanHint')
|
||||
: t('structuredViews.intro.activateTableHint')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={enabling}
|
||||
onClick={onEnable}
|
||||
className={cn(
|
||||
'px-6 py-2.5 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all',
|
||||
'bg-foreground text-background hover:opacity-90 disabled:opacity-50',
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
disabled={enabling}
|
||||
onClick={onEnable}
|
||||
className={cn(
|
||||
'px-6 py-2.5 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all',
|
||||
'bg-foreground text-background hover:opacity-90 disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
{enabling
|
||||
? t('structuredViews.intro.enabling')
|
||||
: target === 'kanban'
|
||||
? t('structuredViews.intro.enableKanban')
|
||||
: t('structuredViews.intro.enableTable')}
|
||||
</button>
|
||||
{onOpenWizard && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenWizard}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 rounded-full text-[11px] font-semibold transition-all border border-border/60 text-muted-foreground hover:text-foreground hover:border-border"
|
||||
>
|
||||
<Wand2 size={13} />
|
||||
{t('structuredViews.wizard.openFromKanban') || 'Configurer avec l\'assistant'}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{enabling
|
||||
? t('structuredViews.intro.enabling')
|
||||
: target === 'kanban'
|
||||
? t('structuredViews.intro.enableKanban')
|
||||
: t('structuredViews.intro.enableTable')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -32,8 +32,8 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
const json = await res.json();
|
||||
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
|
||||
},
|
||||
staleTime: 5000,
|
||||
refetchInterval: 10000,
|
||||
staleTime: 0,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,6 +71,7 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
'chat',
|
||||
'brainstorm_create', // Sera affiché comme "Sessions brainstorm"
|
||||
'suggest_charts',
|
||||
'publish_enhance',
|
||||
]);
|
||||
|
||||
const featureLabels: Record<string, string> = {
|
||||
@@ -81,6 +82,7 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
chat: t('usageMeter.featureChat'),
|
||||
brainstorm_create: t('usageMeter.featureBrainstormSessions'), // Label simplifié
|
||||
suggest_charts: t('usageMeter.featureCharts'),
|
||||
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
||||
};
|
||||
|
||||
const featureQuotas = Object.entries(data.quotas)
|
||||
|
||||
@@ -43,6 +43,7 @@ export function NotebookOrganizerDialog({
|
||||
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
||||
} else {
|
||||
setResult(data)
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Erreur')
|
||||
@@ -133,7 +134,7 @@ export function NotebookOrganizerDialog({
|
||||
: 'bg-foreground/5 hover:bg-foreground/10 text-foreground'
|
||||
)}
|
||||
>
|
||||
{appliedTags.has(tag.name) ? (<><Check size={10} className="inline" /> Appliqué</>) : (t('wizard.apply') || 'Appliquer')}
|
||||
{appliedTags.has(tag.name) ? (<><Check size={10} className="inline" /> {t('wizard.tagApplied') || 'Appliqué'}</>) : (t('wizard.apply') || 'Appliquer')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
398
memento-note/components/wizard/notebook-site-dialog.tsx
Normal file
398
memento-note/components/wizard/notebook-site-dialog.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Sparkles, Globe, Check, Loader2, ExternalLink, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
|
||||
type Step = 'loading' | 'published' | 'idle' | 'analyzing' | 'selection' | 'publishing' | 'done' | 'unpublishing'
|
||||
|
||||
interface NoteItem {
|
||||
id: string
|
||||
title: string
|
||||
include: boolean
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface NotebookSiteDialogProps {
|
||||
notebookId: string
|
||||
notebookName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const TEMPLATES = [
|
||||
{ id: 'magazine', label: 'Magazine', desc: 'Sombre, Playfair Display' },
|
||||
{ id: 'brief', label: 'Brief', desc: 'Professionnel, sobre' },
|
||||
{ id: 'essay', label: 'Essai', desc: 'Élégant, ivoire' },
|
||||
] as const
|
||||
|
||||
export function NotebookSiteDialog({ notebookId, notebookName, onClose }: NotebookSiteDialogProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
|
||||
const [step, setStep] = useState<Step>('loading')
|
||||
const [existingSlug, setExistingSlug] = useState<string | null>(null)
|
||||
const [notes, setNotes] = useState<NoteItem[]>([])
|
||||
const [description, setDescription] = useState('')
|
||||
const [template, setTemplate] = useState<'magazine' | 'brief' | 'essay'>('magazine')
|
||||
const [siteUrl, setSiteUrl] = useState('')
|
||||
|
||||
// Charger l'état existant au montage
|
||||
useEffect(() => {
|
||||
fetch(`/api/notebooks/${notebookId}/publish`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.site?.slug) {
|
||||
setExistingSlug(data.site.slug)
|
||||
setSiteUrl(`/c/${data.site.slug}`)
|
||||
setTemplate(data.site.template || 'magazine')
|
||||
setStep('published')
|
||||
} else {
|
||||
setStep('idle')
|
||||
}
|
||||
})
|
||||
.catch(() => setStep('idle'))
|
||||
}, [notebookId])
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
setStep('analyzing')
|
||||
try {
|
||||
const res = await fetch('/api/ai/notebook-publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notebookId, language }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur'))
|
||||
setStep(existingSlug ? 'published' : 'idle')
|
||||
return
|
||||
}
|
||||
const items: NoteItem[] = (data.notes || []).map((n: any) => ({
|
||||
id: n.noteId,
|
||||
title: data.noteTitles?.[n.noteId] || n.noteId,
|
||||
include: n.include,
|
||||
reason: n.reason,
|
||||
}))
|
||||
setNotes(items)
|
||||
setDescription(data.description || '')
|
||||
setStep('selection')
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Erreur')
|
||||
setStep(existingSlug ? 'published' : 'idle')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async () => {
|
||||
const selected = notes.filter(n => n.include).map(n => n.id)
|
||||
if (selected.length === 0) { toast.error('Sélectionne au moins une note'); return }
|
||||
setStep('publishing')
|
||||
try {
|
||||
const res = await fetch(`/api/notebooks/${notebookId}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ selectedNoteIds: selected, template, description }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast.error(data.error || 'Erreur'); setStep('selection'); return }
|
||||
setExistingSlug(data.slug)
|
||||
setSiteUrl(data.url)
|
||||
setStep('done')
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Erreur')
|
||||
setStep('selection')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublish = async () => {
|
||||
setStep('unpublishing')
|
||||
try {
|
||||
const res = await fetch(`/api/notebooks/${notebookId}/publish`, { method: 'DELETE' })
|
||||
if (!res.ok) { toast.error('Erreur'); setStep('published'); return }
|
||||
setExistingSlug(null)
|
||||
setSiteUrl('')
|
||||
toast.success('Site dépublié')
|
||||
setStep('idle')
|
||||
} catch {
|
||||
toast.error('Erreur')
|
||||
setStep('published')
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCount = notes.filter(n => n.include).length
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: .96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: .96 }}
|
||||
className="relative bg-background border border-border rounded-2xl shadow-2xl w-full max-w-lg flex flex-col max-h-[90vh]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-border">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Globe size={18} className="text-brand-accent" />
|
||||
<div>
|
||||
<p className="text-[13px] font-bold">{t('notebookSite.title') || 'Publier en site web'}</p>
|
||||
<p className="text-[11px] text-muted-foreground truncate max-w-[280px]">{notebookName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors p-1">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
{/* Chargement initial */}
|
||||
{step === 'loading' && (
|
||||
<motion.div key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex justify-center py-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Déjà publié */}
|
||||
{step === 'published' && (
|
||||
<motion.div key="published" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="space-y-4">
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/30 p-4 flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/50 flex items-center justify-center shrink-0">
|
||||
<Check size={15} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-bold text-green-800 dark:text-green-300 mb-1">Site en ligne</p>
|
||||
<a
|
||||
href={siteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[11px] font-mono text-green-700 dark:text-green-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
memento-note.com{siteUrl} <ExternalLink size={10} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setStep('idle')}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 rounded-xl border border-border hover:bg-muted/50 transition-colors text-[13px] font-semibold text-left"
|
||||
>
|
||||
<RefreshCw size={15} className="text-brand-accent" />
|
||||
<div>
|
||||
<p>{t('notebookSite.updateSite') || 'Mettre à jour le site'}</p>
|
||||
<p className="text-[11px] text-muted-foreground font-normal">Relancer l'analyse IA et modifier la sélection</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleUnpublish}
|
||||
className="w-full flex items-center gap-2 px-4 py-3 rounded-xl border border-red-200 dark:border-red-900 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors text-[13px] font-semibold text-left text-red-600 dark:text-red-400"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
<div>
|
||||
<p>{t('notebookSite.unpublish') || 'Dépublier le site'}</p>
|
||||
<p className="text-[11px] text-red-400 font-normal">Le lien public ne sera plus accessible</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Dépublication en cours */}
|
||||
{step === 'unpublishing' && (
|
||||
<motion.div key="unpublishing" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4 py-10">
|
||||
<Loader2 size={28} className="animate-spin text-red-500" />
|
||||
<p className="text-[13px] text-muted-foreground">Dépublication en cours...</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Étape 1 : explication */}
|
||||
{step === 'idle' && (
|
||||
<motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="space-y-4">
|
||||
<div className="rounded-xl border border-border/50 bg-muted/30 p-4 space-y-3">
|
||||
<p className="text-[13px] font-semibold">{t('notebookSite.howItWorks') || 'Comment ça marche'}</p>
|
||||
<ol className="space-y-2 text-[12px] text-muted-foreground">
|
||||
{[
|
||||
t('notebookSite.step1') || "L'IA analyse vos notes et recommande lesquelles publier",
|
||||
t('notebookSite.step2') || "Vous validez ou modifiez la sélection",
|
||||
t('notebookSite.step3') || "Votre site est généré avec navigation et table des matières",
|
||||
].map((s, i) => (
|
||||
<li key={i} className="flex items-start gap-2.5">
|
||||
<span className="shrink-0 w-4 h-4 rounded-full bg-brand-accent/20 text-brand-accent text-[10px] font-bold flex items-center justify-center mt-0.5">{i + 1}</span>
|
||||
{s}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('notebookSite.quotaWarning') || "L'analyse consomme 1 crédit IA."}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Analyse en cours */}
|
||||
{step === 'analyzing' && (
|
||||
<motion.div key="analyzing" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4 py-10">
|
||||
<Loader2 size={30} className="animate-spin text-brand-accent" />
|
||||
<p className="text-[13px] text-muted-foreground">{t('notebookSite.analyzing') || "L'IA analyse vos notes..."}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Sélection */}
|
||||
{step === 'selection' && (
|
||||
<motion.div key="selection" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-4">
|
||||
{description && (
|
||||
<div className="rounded-xl bg-brand-accent/5 border border-brand-accent/20 p-3">
|
||||
<p className="text-[11px] font-bold text-brand-accent uppercase tracking-widest mb-1">{t('notebookSite.siteDescription') || 'Description générée'}</p>
|
||||
<p className="text-[12px] text-foreground leading-relaxed">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
<strong>{selectedCount}</strong> / {notes.length} {t('notebookSite.notesSelected') || 'notes sélectionnées'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{notes.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => setNotes(prev => prev.map(n => n.id === note.id ? { ...n, include: !n.include } : n))}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg border p-2.5 transition-colors flex items-start gap-3',
|
||||
note.include
|
||||
? 'border-brand-accent/40 bg-brand-accent/5'
|
||||
: 'border-border/40 bg-muted/10 opacity-55',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'shrink-0 w-4 h-4 rounded border flex items-center justify-center mt-0.5',
|
||||
note.include ? 'bg-brand-accent border-brand-accent' : 'border-muted-foreground/30 bg-background',
|
||||
)}>
|
||||
{note.include && <Check size={9} className="text-white" strokeWidth={3} />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-semibold text-foreground truncate">{note.title || 'Sans titre'}</p>
|
||||
{note.reason && <p className="text-[10px] text-muted-foreground mt-0.5 leading-relaxed">{note.reason}</p>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest mb-2">{t('notebookSite.template') || 'Style visuel'}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TEMPLATES.map(tpl => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => setTemplate(tpl.id)}
|
||||
className={cn(
|
||||
'rounded-lg border p-2.5 text-left transition-colors',
|
||||
template === tpl.id
|
||||
? 'border-brand-accent bg-brand-accent/5'
|
||||
: 'border-border/50 hover:border-border',
|
||||
)}
|
||||
>
|
||||
<p className="text-[11px] font-bold">{tpl.label}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{tpl.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Publication en cours */}
|
||||
{step === 'publishing' && (
|
||||
<motion.div key="publishing" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-4 py-10">
|
||||
<Loader2 size={30} className="animate-spin text-brand-accent" />
|
||||
<p className="text-[13px] text-muted-foreground">{t('notebookSite.publishing') || 'Génération du site...'}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Succès */}
|
||||
{step === 'done' && (
|
||||
<motion.div key="done" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center gap-5 py-6 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-green-100 dark:bg-green-950/40 flex items-center justify-center">
|
||||
<Check size={26} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[16px] font-bold mb-1">{t('notebookSite.published') || 'Site publié !'}</p>
|
||||
<p className="text-[12px] text-muted-foreground">{selectedCount} notes · accessible publiquement</p>
|
||||
</div>
|
||||
<a
|
||||
href={siteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-6 py-2.5 rounded-full bg-brand-accent text-white text-[12px] font-bold hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
{t('notebookSite.openSite') || 'Ouvrir le site'}
|
||||
</a>
|
||||
<p className="text-[10px] text-muted-foreground font-mono break-all">memento-note.com{siteUrl}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="p-4 border-t border-border flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{(step === 'selection' || step === 'idle') && existingSlug && (
|
||||
<button
|
||||
onClick={() => setStep('published')}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Retour au site actuel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{step === 'idle' && (
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-accent text-white text-[12px] font-bold hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
{t('notebookSite.analyzeBtn') || "Analyser avec l'IA"}
|
||||
</button>
|
||||
)}
|
||||
{step === 'selection' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setStep('idle')}
|
||||
className="px-3 py-2 rounded-lg text-[12px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={selectedCount === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-accent text-white text-[12px] font-bold hover:opacity-90 disabled:opacity-40 transition-opacity"
|
||||
>
|
||||
<Globe size={13} />
|
||||
{t('notebookSite.publishBtn') || 'Publier'} ({selectedCount})
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(step === 'done' || step === 'published') && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg bg-foreground text-background text-[12px] font-bold hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export function StudyPlannerDialog({
|
||||
} else {
|
||||
setPlan(data)
|
||||
toast.success(t('wizard.studyPlanSuccess') || 'Planning créé ! Des rappels ont été ajoutés à vos notes.')
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || 'Erreur')
|
||||
|
||||
@@ -4,7 +4,7 @@ Clipper web avec **panneau latéral** : le panneau reste ouvert pendant que vous
|
||||
|
||||
## Langues
|
||||
|
||||
L’extension suit la **langue de l’interface Chrome** (`chrome.i18n.getUILanguage`) — 15 locales comme l’app Momento : `de`, `en`, `es`, `fr`, `it`, `pt`, `nl`, `pl`, `ru`, `zh`, `ja`, `ko`, `ar`, `fa`, `hi`.
|
||||
L’extension suit la **langue de l’interface Chrome** (`chrome.i18n.getUILanguage`) — 15 locales comme l’app Memento : `de`, `en`, `es`, `fr`, `it`, `pt`, `nl`, `pl`, `ru`, `zh`, `ja`, `ko`, `ar`, `fa`, `hi`.
|
||||
|
||||
Fichiers : `extension/_locales/<lang>/messages.json`. Régénération : `node extension/i18n/generate-translations.cjs` puis `node extension/scripts/build-extension-locales.mjs`.
|
||||
|
||||
@@ -12,11 +12,11 @@ Fichiers : `extension/_locales/<lang>/messages.json`. Régénération : `node ex
|
||||
|
||||
1. Chrome → `chrome://extensions`
|
||||
2. **Mode développeur** → **Charger l’extension non empaquetée** → dossier `memento-note/extension`
|
||||
3. Épingle l’icône Momento
|
||||
3. Épingle l’icône Memento
|
||||
|
||||
> Chrome **114+** requis (Side Panel API).
|
||||
|
||||
## Instance Momento
|
||||
## Instance Memento
|
||||
|
||||
- **Dev** : icône ⚙ → URL (`http://localhost:3000` ou IP LAN) → **Appliquer & reconnecter**
|
||||
- Connectez-vous sur **la même URL** dans Chrome (Google OAuth)
|
||||
@@ -25,18 +25,18 @@ Fichiers : `extension/_locales/<lang>/messages.json`. Régénération : `node ex
|
||||
## Utilisation
|
||||
|
||||
1. Ouvrez une page web normale (pas `chrome://`)
|
||||
2. Cliquez l’icône Momento → panneau latéral
|
||||
2. Cliquez l’icône Memento → panneau latéral
|
||||
3. Choisissez le **carnet** (liste hiérarchique)
|
||||
4. Optionnel : surlignez du texte → **Clipper la sélection** (bouton sky)
|
||||
5. Ou **Clipper cette page** (article complet + IA)
|
||||
6. Ou **Enregistrer le lien seul**
|
||||
7. **Aperçu** : titre éditable, résumé, extrait, temps de lecture → **Enregistrer dans Momento**
|
||||
7. **Aperçu** : titre éditable, résumé, extrait, temps de lecture → **Enregistrer dans Memento**
|
||||
|
||||
## Dépannage
|
||||
|
||||
| Problème | Solution |
|
||||
|----------|----------|
|
||||
| Carnets vides / 401 | **Ouvrir Momento ↗** sur la même URL, connectez-vous |
|
||||
| Carnets vides / 401 | **Ouvrir Memento ↗** sur la même URL, connectez-vous |
|
||||
| `localhost` vs `127.0.0.1` | Utilisez **toujours la même** URL partout (cookies session) |
|
||||
| Pas de sélection | Rechargez la page après install extension ; surlignez sur la page, pas dans le panneau |
|
||||
| Page Chrome système | Impossible — ouvrez un site http(s) normal |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"message": "مومنتو ويب كليبر"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "التقط صفحات الويب والنص المميز في دفاتر ملاحظات Momento الخاصة بك - ويتصل بخادم Momento الخاص بك."
|
||||
"message": "التقط صفحات الويب والنص المميز في دفاتر ملاحظات Memento الخاصة بك - ويتصل بخادم Memento الخاص بك."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "مقطع إلى مومنتو"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "عنوان URL لمومنتو"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "عنوان URL لمثيل Momento الخاص بك"
|
||||
"message": "عنوان URL لمثيل Memento الخاص بك"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "إعداد مسبق للإنتاج · memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "افتح مومنتو"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Momento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
|
||||
"message": "الصق عنوان URL الخاص بـ HTTPS (أو LAN) لخادم Memento الخاص بك. تتعامل ملفات تعريف الارتباط الموجودة في هذا المتصفح مع تسجيل الدخول."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<الإصدار>>>"
|
||||
"message": "Memento Web Clipper <<<الإصدار>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "لا يستطيع Momento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
|
||||
"message": "لا يستطيع Memento الوصول إلى علامة التبويب هذه. تحقق من أذونات ملحق لوحة المفاتيح/الموقع — أو افتح اللوحة الجانبية."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "دفتر بلا عنوان"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "لا يمكن القص هنا — هذه الصفحة تحظر الوصول إلى الإضافات."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "يرجى تسجيل الدخول إلى Momento في هذا المتصفح أولاً."
|
||||
"message": "يرجى تسجيل الدخول إلى Memento في هذا المتصفح أولاً."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "تعذر تحميل دفاتر الملاحظات. حاول إعادة الاتصال."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "صفحة مقيدة - قم بالقص عبر شريط أدوات Momento أو اللوحة الجانبية."
|
||||
"message": "صفحة مقيدة - قم بالقص عبر شريط أدوات Memento أو اللوحة الجانبية."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "دفتر الوجهة"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "لا يمكن إكماله"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "حدث خطأ ما أثناء الوصول إلى مثيل Momento."
|
||||
"message": "حدث خطأ ما أثناء الوصول إلى مثيل Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "أعد المحاولة"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "لا يمكن حفظ ملاحظتك."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "مشكلة في الشبكة - تحقق من اتصالك وعنوان URL الخاص بـ Momento."
|
||||
"message": "مشكلة في الشبكة - تحقق من اتصالك وعنوان URL الخاص بـ Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "قم بتمييز النص الموجود على الصفحة، أو قم بقص الصفحة بأكملها."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Erfassen Sie Webseiten und hervorgehobenen Text in Ihren Momento-Notizbüchern – stellt eine Verbindung zu Ihrem eigenen Momento-Server her."
|
||||
"message": "Erfassen Sie Webseiten und hervorgehobenen Text in Ihren Memento-Notizbüchern – stellt eine Verbindung zu Ihrem eigenen Memento-Server her."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip auf Momento"
|
||||
"message": "Clip auf Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Web Clipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Nicht verbunden"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Momento-URL"
|
||||
"message": "Memento-URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Ihre Momento-Instanz-URL"
|
||||
"message": "Ihre Memento-Instanz-URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Produktionsvoreinstellung · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Anwenden und erneut verbinden"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Öffnen Sie Momento"
|
||||
"message": "Öffnen Sie Memento"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Momento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
|
||||
"message": "Fügen Sie die HTTPS- (oder LAN-)URL Ihres Memento-Servers ein. Cookies in diesem Browser verarbeiten die Anmeldung."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen – oder öffnen Sie den Seitenbereich."
|
||||
"message": "Memento kann nicht auf diese Registerkarte zugreifen. Überprüfen Sie die Tastatur-/Site-Erweiterungsberechtigungen – oder öffnen Sie den Seitenbereich."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Notizbuch ohne Titel"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Hier kann kein Clip erstellt werden – diese Seite blockiert den Zugriff auf die Erweiterung."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Bitte melden Sie sich zunächst in diesem Browser bei Momento an."
|
||||
"message": "Bitte melden Sie sich zunächst in diesem Browser bei Memento an."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Notebooks konnten nicht geladen werden. Versuchen Sie, die Verbindung wiederherzustellen."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Eingeschränkte Seite – Ausschneiden über die Momento-Symbolleiste oder den Seitenbereich."
|
||||
"message": "Eingeschränkte Seite – Ausschneiden über die Memento-Symbolleiste oder den Seitenbereich."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Zielnotizbuch"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Auszug"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "In Momento speichern"
|
||||
"message": "In Memento speichern"
|
||||
},
|
||||
"back": {
|
||||
"message": "Zurück"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "In Momento ansehen"
|
||||
"message": "In Memento ansehen"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Schneiden Sie eine weitere Seite aus"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Konnte nicht abgeschlossen werden"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Beim Erreichen Ihrer Momento-Instanz ist ein Fehler aufgetreten."
|
||||
"message": "Beim Erreichen Ihrer Memento-Instanz ist ein Fehler aufgetreten."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Wiederholen"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Ihre Notiz konnte nicht gespeichert werden."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Netzwerkproblem – überprüfen Sie Ihre Verbindung und Momento-URL."
|
||||
"message": "Netzwerkproblem – überprüfen Sie Ihre Verbindung und Memento-URL."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Markieren Sie Text auf der Seite oder schneiden Sie die gesamte Seite aus."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Capture web pages and highlighted text into your Momento notebooks — connects to your own Momento server."
|
||||
"message": "Capture web pages and highlighted text into your Memento notebooks — connects to your own Memento server."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip to Momento"
|
||||
"message": "Clip to Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Web Clipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Not connected"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Momento URL"
|
||||
"message": "Memento URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Your Momento instance URL"
|
||||
"message": "Your Memento instance URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Production preset · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Apply and reconnect"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Open Momento"
|
||||
"message": "Open Memento"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Paste the HTTPS (or LAN) URL of your Momento server. Cookies in this browser handle sign-in."
|
||||
"message": "Paste the HTTPS (or LAN) URL of your Memento server. Cookies in this browser handle sign-in."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
|
||||
"message": "Memento can't access this tab. Check keyboard/site extension permissions — or open the Side Panel."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Untitled notebook"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Can't clip here — this page blocks extension access."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Please sign in to Momento in this browser first."
|
||||
"message": "Please sign in to Memento in this browser first."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Could not load notebooks. Try reconnecting."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Restricted page — clip via the Momento toolbar or Side Panel."
|
||||
"message": "Restricted page — clip via the Memento toolbar or Side Panel."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Destination notebook"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Excerpt"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Save to Momento"
|
||||
"message": "Save to Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Back"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "View in Momento"
|
||||
"message": "View in Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Clip another page"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Could not complete"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Something went wrong reaching your Momento instance."
|
||||
"message": "Something went wrong reaching your Memento instance."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Retry"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Could not save your note."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Network issue — check your connection and Momento URL."
|
||||
"message": "Network issue — check your connection and Memento URL."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Highlight text on the page, or clip the whole page."
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Cortadora web Momento"
|
||||
"message": "Cortadora web Memento"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Capture páginas web y texto resaltado en sus cuadernos Momento: se conecta a su propio servidor Momento."
|
||||
"message": "Capture páginas web y texto resaltado en sus cuadernos Memento: se conecta a su propio servidor Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip al momento"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "URL del momento"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "La URL de tu instancia de Momento"
|
||||
"message": "La URL de tu instancia de Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Preajuste de producción · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Aplicar y reconectar"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento abierto"
|
||||
"message": "Memento abierto"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Pegue la URL HTTPS (o LAN) de su servidor Momento. Las cookies en este navegador controlan el inicio de sesión."
|
||||
"message": "Pegue la URL HTTPS (o LAN) de su servidor Memento. Las cookies en este navegador controlan el inicio de sesión."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<VERSIÓN>>>"
|
||||
"message": "Memento Web Clipper <<<VERSIÓN>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
|
||||
"message": "Memento no puede acceder a esta pestaña. Verifique los permisos de extensión del sitio/teclado o abra el Panel lateral."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Cuaderno sin título"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "No se puede recortar aquí: esta página bloquea el acceso a la extensión."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Primero inicie sesión en Momento en este navegador."
|
||||
"message": "Primero inicie sesión en Memento en este navegador."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "No se pudieron cargar los cuadernos. Intente volver a conectarse."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Página restringida: recorte mediante la barra de herramientas de Momento o el panel lateral."
|
||||
"message": "Página restringida: recorte mediante la barra de herramientas de Memento o el panel lateral."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Cuaderno de destino"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "No se pudo completar"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Algo salió mal al llegar a tu instancia de Momento."
|
||||
"message": "Algo salió mal al llegar a tu instancia de Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Rever"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "No se pudo guardar tu nota."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problema de red: verifique su conexión y la URL de Momento."
|
||||
"message": "Problema de red: verifique su conexión y la URL de Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Resalte el texto de la página o recorte toda la página."
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "صفحات وب و متن هایلایت شده را در نوت بوک های Momento خود ضبط کنید — به سرور Momento خودتان متصل می شود."
|
||||
"message": "صفحات وب و متن هایلایت شده را در نوت بوک های Memento خود ضبط کنید — به سرور Memento خودتان متصل می شود."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "کلیپ به لحظه"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "آدرس لحظه ای"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "URL نمونه Momento شما"
|
||||
"message": "URL نمونه Memento شما"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "پیش تنظیم تولید · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "درخواست کنید و دوباره وصل شوید"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento را باز کنید"
|
||||
"message": "Memento را باز کنید"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "URL HTTPS (یا LAN) سرور Momento خود را جایگذاری کنید. کوکیهای این مرورگر ورود به سیستم را کنترل میکنند."
|
||||
"message": "URL HTTPS (یا LAN) سرور Memento خود را جایگذاری کنید. کوکیهای این مرورگر ورود به سیستم را کنترل میکنند."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
|
||||
"message": "Memento نمی تواند به این برگه دسترسی پیدا کند. مجوزهای افزونه صفحه کلید/سایت را بررسی کنید - یا پانل جانبی را باز کنید."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "دفترچه بدون عنوان"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "در اینجا نمی توان کلیپ کرد - این صفحه دسترسی برنامه های افزودنی را مسدود می کند."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "لطفاً ابتدا با این مرورگر وارد Momento شوید."
|
||||
"message": "لطفاً ابتدا با این مرورگر وارد Memento شوید."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "نوتبوکها بارگیری نشد. سعی کنید دوباره وصل شوید."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "صفحه محدود - از طریق نوار ابزار Momento یا پانل جانبی کلیپ کنید."
|
||||
"message": "صفحه محدود - از طریق نوار ابزار Memento یا پانل جانبی کلیپ کنید."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "دفترچه یادداشت مقصد"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "گزیده"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "ذخیره در Momento"
|
||||
"message": "ذخیره در Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "برگشت"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "مشاهده در Momento"
|
||||
"message": "مشاهده در Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "یک صفحه دیگر را کلیپ کنید"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "تکمیل نشد"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "هنگام رسیدن به نمونه Momento شما مشکلی پیش آمد."
|
||||
"message": "هنگام رسیدن به نمونه Memento شما مشکلی پیش آمد."
|
||||
},
|
||||
"retry": {
|
||||
"message": "دوباره امتحان کنید"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "یادداشت شما ذخیره نشد."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "مشکل شبکه - اتصال و URL Momento خود را بررسی کنید."
|
||||
"message": "مشکل شبکه - اتصال و URL Memento خود را بررسی کنید."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "متن را در صفحه برجسته کنید یا کل صفحه را برش دهید."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento · Web Clipper"
|
||||
"message": "Memento · Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Enregistrez des pages web et du texte surligné dans vos carnets Momento — connecté à votre propre serveur Momento."
|
||||
"message": "Enregistrez des pages web et du texte surligné dans vos carnets Memento — connecté à votre propre serveur Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clipper vers Momento"
|
||||
"message": "Clipper vers Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Web Clipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Non connecté"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Adresse Momento"
|
||||
"message": "Adresse Memento"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "URL de votre instance Momento"
|
||||
"message": "URL de votre instance Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Préréglage production · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Appliquer et reconnecter"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Ouvrir Momento"
|
||||
"message": "Ouvrir Memento"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Momento. Les cookies de ce navigateur gèrent la connexion."
|
||||
"message": "Collez l'URL HTTPS (ou LAN) de votre serveur Memento. Les cookies de ce navigateur gèrent la connexion."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper 0.3.1"
|
||||
"message": "Memento Web Clipper 0.3.1"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento ne peut pas accéder à cet onglet. Vérifiez les autorisations du clavier/extension de site – ou ouvrez le panneau latéral."
|
||||
"message": "Memento ne peut pas accéder à cet onglet. Vérifiez les autorisations du clavier/extension de site – ou ouvrez le panneau latéral."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Carnet sans titre"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Impossible de clipper ici — cette page bloque l'accès aux extensions."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Veuillez d'abord vous connecter à Momento dans ce navigateur."
|
||||
"message": "Veuillez d'abord vous connecter à Memento dans ce navigateur."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Impossible de charger les carnets. Essayez de vous reconnecter."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Page restreinte : clip via la barre d'outils Momento ou le panneau latéral."
|
||||
"message": "Page restreinte : clip via la barre d'outils Memento ou le panneau latéral."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Carnet de destination"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Extrait"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Enregistrer dans Momento"
|
||||
"message": "Enregistrer dans Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Retour"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Voir dans Momento"
|
||||
"message": "Voir dans Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Clipper une autre page"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Impossible de terminer"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Une erreur est survenue lors de la communication avec votre instance Momento."
|
||||
"message": "Une erreur est survenue lors de la communication avec votre instance Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Réessayer"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Impossible d'enregistrer votre note."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problème de réseau : vérifiez votre connexion et l'URL Momento."
|
||||
"message": "Problème de réseau : vérifiez votre connexion et l'URL Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Surlignez le texte à clipper"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Web Clipper"
|
||||
"message": "Memento Web Clipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Cattura pagine web e testo evidenziato nei tuoi taccuini Momento: si connette al tuo server Momento."
|
||||
"message": "Cattura pagine web e testo evidenziato nei tuoi taccuini Memento: si connette al tuo server Memento."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip su Momento"
|
||||
"message": "Clip su Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Tagliatore di fotoricettore"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "URL del momento"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "L'URL dell'istanza di Momento"
|
||||
"message": "L'URL dell'istanza di Memento"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Preimpostazione di produzione · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Applicare e riconnettersi"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento aperto"
|
||||
"message": "Memento aperto"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Momento. I cookie in questo browser gestiscono l'accesso."
|
||||
"message": "Incolla l'URL HTTPS (o LAN) del tuo server Memento. I cookie in questo browser gestiscono l'accesso."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<VERSIONE>>>"
|
||||
"message": "Memento Web Clipper <<<VERSIONE>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
|
||||
"message": "Memento non può accedere a questa scheda. Controlla le autorizzazioni per tastiera/estensione del sito oppure apri il pannello laterale."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Taccuino senza titolo"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Impossibile ritagliare qui: questa pagina blocca l'accesso all'estensione."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Accedi prima a Momento in questo browser."
|
||||
"message": "Accedi prima a Memento in questo browser."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Impossibile caricare i taccuini. Prova a riconnetterti."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Pagina limitata: ritaglia tramite la barra degli strumenti Momento o il pannello laterale."
|
||||
"message": "Pagina limitata: ritaglia tramite la barra degli strumenti Memento o il pannello laterale."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Taccuino di destinazione"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Estratto"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Salva su Momento"
|
||||
"message": "Salva su Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Indietro"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Visualizza in Momento"
|
||||
"message": "Visualizza in Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Ritaglia un'altra pagina"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Impossibile completare"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Qualcosa è andato storto nel raggiungere la tua istanza Momento."
|
||||
"message": "Qualcosa è andato storto nel raggiungere la tua istanza Memento."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Riprova"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Impossibile salvare la nota."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Problema di rete: controlla la connessione e l'URL Momento."
|
||||
"message": "Problema di rete: controlla la connessione e l'URL Memento."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Evidenzia il testo sulla pagina o ritaglia l'intera pagina."
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"message": "モーメントウェブクリッパー"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Web ページとハイライトされたテキストを Momento ノートブックにキャプチャします。独自の Momento サーバーに接続します。"
|
||||
"message": "Web ページとハイライトされたテキストを Memento ノートブックにキャプチャします。独自の Memento サーバーに接続します。"
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "モーメントにクリップ"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "モーメントのURL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Momento インスタンスの URL"
|
||||
"message": "Memento インスタンスの URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "プロダクションプリセット・memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "モーメントを開く"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Momento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
|
||||
"message": "Memento サーバーの HTTPS (または LAN) URL を貼り付けます。このブラウザの Cookie がサインインを処理します。"
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web クリッパー <<<バージョン>>>"
|
||||
"message": "Memento Web クリッパー <<<バージョン>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
|
||||
"message": "Memento はこのタブにアクセスできません。キーボード/サイト拡張機能の権限を確認するか、サイド パネルを開きます。"
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "無題のノート"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "ここではクリップできません — このページは拡張機能へのアクセスをブロックしています。"
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "まずこのブラウザで Momento にサインインしてください。"
|
||||
"message": "まずこのブラウザで Memento にサインインしてください。"
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "ノートブックをロードできませんでした。再接続してみてください。"
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "制限されたページ — Momento ツールバーまたはサイド パネルを介してクリップします。"
|
||||
"message": "制限されたページ — Memento ツールバーまたはサイド パネルを介してクリップします。"
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "宛先ノートブック"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "完了できませんでした"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Momento インスタンスに到達する際に問題が発生しました。"
|
||||
"message": "Memento インスタンスに到達する際に問題が発生しました。"
|
||||
},
|
||||
"retry": {
|
||||
"message": "リトライ"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "メモを保存できませんでした。"
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "ネットワークの問題 — 接続と Momento URL を確認してください。"
|
||||
"message": "ネットワークの問題 — 接続と Memento URL を確認してください。"
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "ページ上のテキストを強調表示するか、ページ全体をクリップします。"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"message": "모멘토 웹 클리퍼"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "웹 페이지와 강조 표시된 텍스트를 Momento 노트북에 캡처하여 자체 Momento 서버에 연결합니다."
|
||||
"message": "웹 페이지와 강조 표시된 텍스트를 Memento 노트북에 캡처하여 자체 Memento 서버에 연결합니다."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "순간에 클립"
|
||||
@@ -21,7 +21,7 @@
|
||||
"message": "모멘토 URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "귀하의 Momento 인스턴스 URL"
|
||||
"message": "귀하의 Memento 인스턴스 URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "프로덕션 프리셋 · memento-note.com"
|
||||
@@ -33,13 +33,13 @@
|
||||
"message": "모멘토 열기"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Momento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
|
||||
"message": "Memento 서버의 HTTPS(또는 LAN) URL을 붙여넣습니다. 이 브라우저의 쿠키는 로그인을 처리합니다."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<버전>>>"
|
||||
"message": "Memento Web Clipper <<<버전>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
|
||||
"message": "Memento는 이 탭에 접근할 수 없습니다. 키보드/사이트 확장 권한을 확인하거나 측면 패널을 엽니다."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "제목 없는 노트"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "여기서 클립할 수 없습니다. 이 페이지는 확장 프로그램 액세스를 차단합니다."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "먼저 이 브라우저에서 Momento에 로그인하세요."
|
||||
"message": "먼저 이 브라우저에서 Memento에 로그인하세요."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "노트북을 로드할 수 없습니다. 다시 연결해 보세요."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "제한된 페이지 — Momento 도구 모음 또는 측면 패널을 통해 클립합니다."
|
||||
"message": "제한된 페이지 — Memento 도구 모음 또는 측면 패널을 통해 클립합니다."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "대상 노트북"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Momento에서 보기"
|
||||
"message": "Memento에서 보기"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "다른 페이지 자르기"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "완료할 수 없습니다."
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Momento 인스턴스에 연결하는 데 문제가 발생했습니다."
|
||||
"message": "Memento 인스턴스에 연결하는 데 문제가 발생했습니다."
|
||||
},
|
||||
"retry": {
|
||||
"message": "다시 해 보다"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "메모를 저장할 수 없습니다."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "네트워크 문제 - 연결 및 Momento URL을 확인하세요."
|
||||
"message": "네트워크 문제 - 연결 및 Memento URL을 확인하세요."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "페이지의 텍스트를 강조 표시하거나 전체 페이지를 자릅니다."
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"extName": {
|
||||
"message": "Momento Webclipper"
|
||||
"message": "Memento Webclipper"
|
||||
},
|
||||
"extDescription": {
|
||||
"message": "Leg webpagina's en gemarkeerde tekst vast in uw Momento-notebooks - maakt verbinding met uw eigen Momento-server."
|
||||
"message": "Leg webpagina's en gemarkeerde tekst vast in uw Memento-notebooks - maakt verbinding met uw eigen Memento-server."
|
||||
},
|
||||
"extActionTitle": {
|
||||
"message": "Clip naar Momento"
|
||||
"message": "Clip naar Memento"
|
||||
},
|
||||
"webClipper": {
|
||||
"message": "Webclipper"
|
||||
@@ -18,10 +18,10 @@
|
||||
"message": "Niet verbonden"
|
||||
},
|
||||
"instanceSettings": {
|
||||
"message": "Momento-URL"
|
||||
"message": "Memento-URL"
|
||||
},
|
||||
"instanceUrlLabel": {
|
||||
"message": "Uw Momento-instantie-URL"
|
||||
"message": "Uw Memento-instantie-URL"
|
||||
},
|
||||
"presetProduction": {
|
||||
"message": "Productievoorinstelling · memento-note.com"
|
||||
@@ -30,16 +30,16 @@
|
||||
"message": "Toepassen en opnieuw verbinden"
|
||||
},
|
||||
"openMomento": {
|
||||
"message": "Momento openen"
|
||||
"message": "Memento openen"
|
||||
},
|
||||
"settingsHint": {
|
||||
"message": "Plak de HTTPS (of LAN) URL van uw Momento-server. Cookies in deze browser zorgen voor het inloggen."
|
||||
"message": "Plak de HTTPS (of LAN) URL van uw Memento-server. Cookies in deze browser zorgen voor het inloggen."
|
||||
},
|
||||
"footerVersion": {
|
||||
"message": "Momento Web Clipper <<<VERSIE>>>"
|
||||
"message": "Memento Web Clipper <<<VERSIE>>>"
|
||||
},
|
||||
"errPermissionDenied": {
|
||||
"message": "Momento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
|
||||
"message": "Memento heeft geen toegang tot dit tabblad. Controleer de rechten voor toetsenbord-/site-extensies — of open het zijpaneel."
|
||||
},
|
||||
"notebookUnnamed": {
|
||||
"message": "Naamloos notitieboekje"
|
||||
@@ -81,7 +81,7 @@
|
||||
"message": "Kan hier niet knippen: deze pagina blokkeert de toegang tot extensies."
|
||||
},
|
||||
"errLoginRequired": {
|
||||
"message": "Meld u eerst aan bij Momento in deze browser."
|
||||
"message": "Meld u eerst aan bij Memento in deze browser."
|
||||
},
|
||||
"errLoadNotebooks": {
|
||||
"message": "Kan notitieboekjes niet laden. Probeer opnieuw verbinding te maken."
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"restrictedPage": {
|
||||
"message": "Beperkte pagina — clip via de Momento-werkbalk of het zijpaneel."
|
||||
"message": "Beperkte pagina — clip via de Memento-werkbalk of het zijpaneel."
|
||||
},
|
||||
"destinationNotebook": {
|
||||
"message": "Bestemmingsnotitieboekje"
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Uittreksel"
|
||||
},
|
||||
"saveToMomento": {
|
||||
"message": "Opslaan in Momento"
|
||||
"message": "Opslaan in Memento"
|
||||
},
|
||||
"back": {
|
||||
"message": "Rug"
|
||||
@@ -150,7 +150,7 @@
|
||||
}
|
||||
},
|
||||
"viewInMomento": {
|
||||
"message": "Bekijk in Momento"
|
||||
"message": "Bekijk in Memento"
|
||||
},
|
||||
"clipAnother": {
|
||||
"message": "Knip nog een pagina uit"
|
||||
@@ -159,7 +159,7 @@
|
||||
"message": "Kon niet voltooien"
|
||||
},
|
||||
"genericError": {
|
||||
"message": "Er is iets misgegaan bij het bereiken van uw Momento-instantie."
|
||||
"message": "Er is iets misgegaan bij het bereiken van uw Memento-instantie."
|
||||
},
|
||||
"retry": {
|
||||
"message": "Opnieuw proberen"
|
||||
@@ -174,7 +174,7 @@
|
||||
"message": "Kan uw notitie niet opslaan."
|
||||
},
|
||||
"errNetwork": {
|
||||
"message": "Netwerkprobleem: controleer uw verbinding en Momento-URL."
|
||||
"message": "Netwerkprobleem: controleer uw verbinding en Memento-URL."
|
||||
},
|
||||
"bannerPickText": {
|
||||
"message": "Markeer tekst op de pagina of knip de hele pagina uit."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user