Compare commits
143 Commits
7c8695cacf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30da592ba2 | ||
|
|
d38a99586b | ||
|
|
6e13b6d207 | ||
|
|
4d95234e32 | ||
|
|
eb6cda9d24 | ||
|
|
f7da22d409 | ||
|
|
3d783ac9e2 | ||
|
|
fbeea7027c | ||
|
|
1f5dc6af09 | ||
|
|
a84c7e80d6 | ||
|
|
a9e43d7594 | ||
|
|
df2b9c2c7b | ||
|
|
d586048b52 | ||
|
|
5821e2c96f | ||
|
|
261eee2953 | ||
|
|
45fc178589 | ||
|
|
6536180e91 | ||
|
|
ee9d6650a2 | ||
|
|
c8bed7f138 | ||
|
|
a2d8d90e3d | ||
|
|
8b454b9cf4 | ||
|
|
36b5724b72 | ||
|
|
ce1a13801f | ||
|
|
c271754cfa | ||
|
|
03a3fb7411 | ||
|
|
e72ca26f97 | ||
|
|
cf5d6a202b | ||
|
|
06c771ee9d | ||
|
|
a7e3646ae6 | ||
|
|
6b44d35c40 | ||
|
|
72f9798e97 | ||
|
|
e48152e294 | ||
|
|
d48312dfc2 | ||
|
|
f9d79365f3 | ||
|
|
58fe5eb54f | ||
|
|
73a3d206b0 | ||
|
|
03dcde44ca | ||
|
|
10101e5918 | ||
|
|
56ce662d38 | ||
|
|
1d17fe2f9a | ||
|
|
030baac309 | ||
|
|
b8c85be40f | ||
|
|
19d446f78e | ||
|
|
ac66d672d6 | ||
|
|
1d4f935683 | ||
|
|
7a9da7f97b | ||
|
|
89d2ffad46 | ||
|
|
a1b39959de | ||
|
|
334fce5fc1 | ||
|
|
a1399a3d7b | ||
|
|
1fc790f0c7 | ||
|
|
1fc6728259 | ||
|
|
056b0260cf | ||
|
|
40292f4c00 | ||
|
|
be500189c3 | ||
|
|
1b61c2c54e | ||
|
|
b3fb46fc52 | ||
|
|
96e7902f01 | ||
|
|
902fe95a69 | ||
|
|
31e882856c | ||
|
|
eab4b3e27b | ||
|
|
52c4cb1dee | ||
|
|
e9e829e579 | ||
|
|
4d96605144 | ||
|
|
40f30155c2 | ||
|
|
128aa842f1 | ||
|
|
35c79ffd1c | ||
|
|
87efb807f3 | ||
|
|
ce596fa947 | ||
|
|
af277f418a | ||
|
|
e07af2084b | ||
|
|
018db001b4 | ||
|
|
af3f130549 | ||
|
|
ee70e74bf5 | ||
|
|
5b13a88b72 | ||
|
|
cb4c1cc9ad | ||
|
|
de83d34a15 | ||
|
|
acbfba85b1 | ||
|
|
3a1e4254df | ||
|
|
17594124b0 | ||
|
|
1774544385 | ||
|
|
3f3d37ebeb | ||
|
|
722cb905e4 | ||
|
|
a2d1926e6e | ||
|
|
e02a9d9a53 | ||
|
|
a6cdcba76f | ||
|
|
1d614dd9c0 | ||
|
|
299836bd74 | ||
|
|
723f7ef432 | ||
|
|
dd28b4f0bd | ||
|
|
52d213da67 | ||
|
|
c9b98065b6 | ||
|
|
85b7e6e396 | ||
|
|
6084077b54 | ||
|
|
2ec31a3de5 | ||
|
|
2ec2654282 | ||
|
|
c21cbf84a1 | ||
|
|
5b9930b02e | ||
|
|
a4238dc204 | ||
|
|
4750686b9f | ||
|
|
82f87b65cb | ||
|
|
b9a80f9e64 | ||
|
|
eff906d187 | ||
|
|
08d190eb03 | ||
|
|
104af3149f | ||
|
|
940c3daf62 | ||
|
|
f7b62009cf | ||
|
|
7fedfa8f50 | ||
|
|
d5b409c1ac | ||
|
|
83110200d5 | ||
|
|
ba3ab3422a | ||
|
|
5246ed41e9 | ||
|
|
2723e06b80 | ||
|
|
fccad72d47 | ||
|
|
a623454347 | ||
|
|
a8785ed4f1 | ||
|
|
cecd8ff27d | ||
|
|
76a8135567 | ||
|
|
8d8e8a20f4 | ||
|
|
7e88eb64f8 | ||
|
|
ff0fae9ae7 | ||
|
|
c266359f63 | ||
|
|
2272fa498a | ||
|
|
b77783ed95 | ||
|
|
87d2b72313 | ||
|
|
5b36b3cf1c | ||
|
|
d0dda2ddc2 | ||
|
|
42aa374be6 | ||
|
|
c21c2d113a | ||
|
|
e711a3501d | ||
|
|
b012869119 | ||
|
|
a1c1729904 | ||
|
|
3edbc2daf3 | ||
|
|
1b56af9743 | ||
|
|
0fa8978395 | ||
|
|
1121b8c345 | ||
|
|
e7290d6f9c | ||
|
|
12d1e3dfdd | ||
|
|
d06ea93f11 | ||
|
|
725bf2c445 | ||
|
|
d2145f761d | ||
|
|
45877db706 | ||
|
|
0ef12f7399 |
142
.agents/skills/find-skills/SKILL.md
Normal file
142
.agents/skills/find-skills/SKILL.md
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: find-skills
|
||||
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
|
||||
---
|
||||
|
||||
# Find Skills
|
||||
|
||||
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when the user:
|
||||
|
||||
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||
- Says "find a skill for X" or "is there a skill for X"
|
||||
- Asks "can you do X" where X is a specialized capability
|
||||
- Expresses interest in extending agent capabilities
|
||||
- Wants to search for tools, templates, or workflows
|
||||
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||
|
||||
## What is the Skills CLI?
|
||||
|
||||
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||
|
||||
**Key commands:**
|
||||
|
||||
- `npx skills find [query] [--owner <owner>]` - Search for skills interactively or by keyword, optionally scoped to a GitHub owner
|
||||
- `npx skills add <package>` - Install a skill from GitHub or other sources
|
||||
- `npx skills check` - Check for skill updates
|
||||
- `npx skills update` - Update all installed skills
|
||||
|
||||
**Browse skills at:** https://skills.sh/
|
||||
|
||||
## How to Help Users Find Skills
|
||||
|
||||
### Step 1: Understand What They Need
|
||||
|
||||
When a user asks for help with something, identify:
|
||||
|
||||
1. The domain (e.g., React, testing, design, deployment)
|
||||
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||
3. Whether this is a common enough task that a skill likely exists
|
||||
|
||||
### Step 2: Check the Leaderboard First
|
||||
|
||||
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
|
||||
|
||||
For example, top skills for web development include:
|
||||
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
|
||||
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
|
||||
|
||||
### Step 3: Search for Skills
|
||||
|
||||
If the leaderboard doesn't cover the user's need, run the find command:
|
||||
|
||||
```bash
|
||||
npx skills find [query] [--owner <owner>]
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||
|
||||
### Step 4: Verify Quality Before Recommending
|
||||
|
||||
**Do not recommend a skill based solely on search results.** Always verify:
|
||||
|
||||
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
|
||||
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
|
||||
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
|
||||
|
||||
### Step 5: Present Options to the User
|
||||
|
||||
When you find relevant skills, present them to the user with:
|
||||
|
||||
1. The skill name and what it does
|
||||
2. The install count and source
|
||||
3. The install command they can run
|
||||
4. A link to learn more at skills.sh
|
||||
|
||||
Example response:
|
||||
|
||||
```
|
||||
I found a skill that might help! The "react-best-practices" skill provides
|
||||
React and Next.js performance optimization guidelines from Vercel Engineering.
|
||||
(185K installs)
|
||||
|
||||
To install it:
|
||||
npx skills add vercel-labs/agent-skills@react-best-practices
|
||||
|
||||
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
|
||||
```
|
||||
|
||||
### Step 6: Offer to Install
|
||||
|
||||
If the user wants to proceed, you can install the skill for them:
|
||||
|
||||
```bash
|
||||
npx skills add <owner/repo@skill> -g -y
|
||||
```
|
||||
|
||||
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
|
||||
|
||||
## Common Skill Categories
|
||||
|
||||
When searching, consider these common categories:
|
||||
|
||||
| Category | Example Queries |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| Web Development | react, nextjs, typescript, css, tailwind |
|
||||
| Testing | testing, jest, playwright, e2e |
|
||||
| DevOps | deploy, docker, kubernetes, ci-cd |
|
||||
| Documentation | docs, readme, changelog, api-docs |
|
||||
| Code Quality | review, lint, refactor, best-practices |
|
||||
| Design | ui, ux, design-system, accessibility |
|
||||
| Productivity | workflow, automation, git |
|
||||
|
||||
## Tips for Effective Searches
|
||||
|
||||
1. **Use specific keywords**: "react testing" is better than just "testing"
|
||||
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
|
||||
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
|
||||
|
||||
## When No Skills Are Found
|
||||
|
||||
If no relevant skills exist:
|
||||
|
||||
1. Acknowledge that no existing skill was found
|
||||
2. Offer to help with the task directly using your general capabilities
|
||||
3. Suggest the user could create their own skill with `npx skills init`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
I searched for skills related to "xyz" but didn't find any matches.
|
||||
I can still help you with this task directly! Would you like me to proceed?
|
||||
|
||||
If this is something you do often, you could create your own skill:
|
||||
npx skills init my-xyz-skill
|
||||
```
|
||||
@@ -31,7 +31,9 @@
|
||||
"mcp__zai-mcp-server__analyze_image",
|
||||
"Bash(npx prisma *)",
|
||||
"Bash(xargs -I{} ls {})",
|
||||
"Bash(node_modules/.bin/tsc --noEmit)"
|
||||
"Bash(node_modules/.bin/tsc --noEmit)",
|
||||
"Bash(python3 /home/devparsa/dev/Momento/_bmad/scripts/resolve_customization.py --skill /home/devparsa/dev/Momento/.claude/skills/bmad-agent-analyst --key agent)",
|
||||
"Bash(unzip -l /home/devparsa/dev/Momento/memento-note/extension/memento-web-clipper-chrome-store.zip)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,71 @@
|
||||
"/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": 1783196585431,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/2bb8ea12-29cb-4456-a5ee-af86d7a427a3.jsonl": 1783196591937,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/372b1e4e-cc60-4117-b3c1-48af0dbc38d9.jsonl": 1782059598659,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/8ad24313-529f-41f7-96b3-dc6314e94c72.jsonl": 1782633223580,
|
||||
"/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/99e20a50-1e7b-4ed8-931a-5c4c7a65ea4d.jsonl": 1782037965469,
|
||||
"/home/devparsa/.cursor/projects/home-devparsa-dev-Momento/agent-transcripts/ec2b194c-c67e-4089-a434-6daff69ca69d/subagents/e483aeb3-3b38-4f7f-9ad8-d79f362c102d.jsonl": 1782058095940,
|
||||
"/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
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"lastRunAtMs": 1779998560332,
|
||||
"turnsSinceLastRun": 3,
|
||||
"lastTranscriptMtimeMs": 1779998515529,
|
||||
"lastProcessedGenerationId": "250d4e92-b72e-4ca2-b0c1-625292c59d32",
|
||||
"lastRunAtMs": 1783196565768,
|
||||
"turnsSinceLastRun": 5,
|
||||
"lastTranscriptMtimeMs": 1783196565691.0437,
|
||||
"lastProcessedGenerationId": "7ac52bd5-5714-460a-9c3b-e4b21907d6d7",
|
||||
"trialStartedAtMs": null
|
||||
}
|
||||
|
||||
@@ -1,121 +1,80 @@
|
||||
# =============================================================================
|
||||
# Memento - Docker Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env.docker and update with your values.
|
||||
# This file is read by docker-compose.yml via env_file directive.
|
||||
# cp .env.docker.example .env.docker
|
||||
## AUTO-MANAGED BY CI — do not edit manually ##
|
||||
## This is a reference template. Real values come from Gitea vars/secrets. ##
|
||||
|
||||
# =============================================================================
|
||||
# APPLICATION URL (REQUIRED)
|
||||
# =============================================================================
|
||||
# Change to your server IP or domain
|
||||
# Examples:
|
||||
# IP: http://192.168.1.190:3000
|
||||
# Domain: http://notes.yourdomain.com
|
||||
# HTTPS: https://notes.yourdomain.com
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
# Core
|
||||
NEXTAUTH_URL=https://memento-note.com
|
||||
NEXTAUTH_SECRET=<secret>
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ALLOW_REGISTRATION=true
|
||||
|
||||
# =============================================================================
|
||||
# AUTHENTICATION SECRET (REQUIRED)
|
||||
# =============================================================================
|
||||
# Generate with: openssl rand -base64 32
|
||||
NEXTAUTH_SECRET="changethisinproduction"
|
||||
|
||||
# =============================================================================
|
||||
# REGISTRATION & ADMIN
|
||||
# =============================================================================
|
||||
# Set to "false" to disable public registration (default: true)
|
||||
# ALLOW_REGISTRATION=true
|
||||
|
||||
# Admin email - The first user registering with this email gets ADMIN role (REQUIRED)
|
||||
# ADMIN_EMAIL="admin@yourdomain.com"
|
||||
|
||||
# Google OAuth — both required to show "Continue with Google" on /login
|
||||
# Redirect URI in Google Console: {NEXTAUTH_URL}/api/auth/callback/google
|
||||
# AUTH_GOOGLE_ID="....apps.googleusercontent.com"
|
||||
# AUTH_GOOGLE_SECRET="GOCSPX-..."
|
||||
|
||||
# =============================================================================
|
||||
# POSTGRESQL CONFIGURATION
|
||||
# =============================================================================
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=memento
|
||||
# PostgreSQL (used by docker-compose to construct DATABASE_URL)
|
||||
POSTGRES_USER=memento
|
||||
POSTGRES_PASSWORD=memento
|
||||
POSTGRES_PASSWORD=<secret>
|
||||
POSTGRES_DB=memento
|
||||
POSTGRES_PORT=5433
|
||||
|
||||
# =============================================================================
|
||||
# MCP SERVER CONFIGURATION
|
||||
# =============================================================================
|
||||
# Mode: 'stdio' (Claude Desktop, Cline) or 'sse' (N8N, HTTP)
|
||||
MCP_MODE="stdio"
|
||||
MCP_PORT="3001"
|
||||
# Frontend MCP settings (for the MCP settings panel in the web UI)
|
||||
# MCP_SERVER_MODE="sse"
|
||||
# MCP_SERVER_URL="http://YOUR_IP:3001"
|
||||
|
||||
# =============================================================================
|
||||
# AI PROVIDER - TAGS GENERATION
|
||||
# =============================================================================
|
||||
# Options: ollama, openai, custom
|
||||
# AI - Tags
|
||||
AI_PROVIDER_TAGS=ollama
|
||||
AI_MODEL_TAGS="granite4:latest"
|
||||
AI_MODEL_TAGS=granite4:latest
|
||||
|
||||
# =============================================================================
|
||||
# AI PROVIDER - EMBEDDINGS
|
||||
# =============================================================================
|
||||
# Options: ollama, openai, custom
|
||||
# AI - Embeddings
|
||||
AI_PROVIDER_EMBEDDING=ollama
|
||||
AI_MODEL_EMBEDDING="embeddinggemma:latest"
|
||||
AI_MODEL_EMBEDDING=embeddinggemma:latest
|
||||
|
||||
# =============================================================================
|
||||
# AI PROVIDER - CHAT (optional, falls back to AI_PROVIDER_TAGS)
|
||||
# =============================================================================
|
||||
# AI_PROVIDER_CHAT=ollama
|
||||
# AI_MODEL_CHAT="granite4:latest"
|
||||
# AI - Chat
|
||||
AI_PROVIDER_CHAT=ollama
|
||||
AI_MODEL_CHAT=granite4:latest
|
||||
|
||||
# =============================================================================
|
||||
# OLLAMA CONFIGURATION (if provider = ollama)
|
||||
# =============================================================================
|
||||
# Docker service: http://ollama:11434
|
||||
# Host machine: http://host.docker.internal:11434
|
||||
# Remote server: http://YOUR_SERVER_IP:11434
|
||||
OLLAMA_BASE_URL="http://ollama:11434"
|
||||
# AI - Custom OpenAI (OpenRouter etc.)
|
||||
CUSTOM_OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
||||
CUSTOM_OPENAI_API_KEY=<secret>
|
||||
OPENAI_API_KEY=<secret>
|
||||
|
||||
# =============================================================================
|
||||
# OPENAI CONFIGURATION (if provider = openai)
|
||||
# =============================================================================
|
||||
# OPENAI_API_KEY="sk-..."
|
||||
# AI - Ollama
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
|
||||
# =============================================================================
|
||||
# CUSTOM OPENAI-COMPATIBLE PROVIDER (if provider = custom)
|
||||
# =============================================================================
|
||||
# Compatible with: OpenRouter, Groq, Together AI, Mistral, etc.
|
||||
# OpenRouter: https://openrouter.ai/api/v1
|
||||
# Groq: https://api.groq.com/openai/v1
|
||||
# Together: https://api.together.xyz/v1
|
||||
# Mistral: https://api.mistral.ai/v1
|
||||
# CUSTOM_OPENAI_API_KEY="your-api-key"
|
||||
# CUSTOM_OPENAI_BASE_URL="https://openrouter.ai/api/v1"
|
||||
# Redis (set by CI, do not override)
|
||||
REDIS_HOST=redis
|
||||
|
||||
# =============================================================================
|
||||
# EMAIL / SMTP (optional, required for password reset)
|
||||
# =============================================================================
|
||||
# SMTP_HOST="smtp.gmail.com"
|
||||
# SMTP_PORT="587"
|
||||
# SMTP_USER="your-email@gmail.com"
|
||||
# SMTP_PASS="your-app-password"
|
||||
# SMTP_FROM="noreply@memento.app"
|
||||
# Email
|
||||
EMAIL_PROVIDER=resend
|
||||
SMTP_FROM=noreply@memento-note.com
|
||||
RESEND_API_KEY=<secret>
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASS=<secret>
|
||||
SMTP_SECURE=
|
||||
SMTP_IGNORE_CERT=
|
||||
|
||||
# =============================================================================
|
||||
# RESEND EMAIL (alternative to SMTP, optional)
|
||||
# =============================================================================
|
||||
# RESEND_API_KEY="re_..."
|
||||
# Google OAuth
|
||||
AUTH_GOOGLE_ID=<var>
|
||||
AUTH_GOOGLE_SECRET=<secret>
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Brainstorm / Socket.io
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
SOCKET_PORT=3005
|
||||
# MCP Server
|
||||
MCP_MODE=sse
|
||||
MCP_PORT=3001
|
||||
MCP_SERVER_MODE=sse
|
||||
MCP_SERVER_URL=https://memento-note.com/mcp
|
||||
MCP_API_KEY=<secret>
|
||||
|
||||
# Web Search
|
||||
WEB_SEARCH_PROVIDER=searxng
|
||||
SEARXNG_URL=http://192.168.1.190:8888
|
||||
BRAVE_SEARCH_API_KEY=<secret>
|
||||
JINA_API_KEY=<secret>
|
||||
|
||||
# Socket (realtime)
|
||||
SOCKET_INTERNAL_KEY=<secret>
|
||||
SOCKET_PORT=3002
|
||||
SOCKET_HTTP_PORT=3003
|
||||
SOCKET_INTERNAL_KEY=change-this-to-a-random-secret
|
||||
SOCKET_INTERNAL_URL=http://memento-socket:3003
|
||||
NEXT_PUBLIC_SOCKET_URL=https://note.parsanet.org
|
||||
SOCKET_INTERNAL_URL=http://memento-socket:3002
|
||||
NEXT_PUBLIC_SOCKET_URL=wss://memento-note.com/ws
|
||||
|
||||
# Telegram notifications
|
||||
TELEGRAM_BOT_TOKEN=<secret>
|
||||
TELEGRAM_CHAT_ID=<var>
|
||||
|
||||
# Monitoring
|
||||
METRICS_TOKEN=<secret>
|
||||
GRAFANA_ADMIN_PASSWORD=<secret>
|
||||
|
||||
@@ -103,8 +103,7 @@ jobs:
|
||||
|
||||
- name: Upload web artifact
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: actions/upload-artifact@v4
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: web-artifact
|
||||
path: web-artifact.tgz
|
||||
@@ -125,8 +124,7 @@ jobs:
|
||||
git reset --hard origin/main
|
||||
|
||||
- name: Download web artifact
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: web-artifact
|
||||
|
||||
@@ -180,11 +178,12 @@ jobs:
|
||||
run: |
|
||||
ENV_FILE="/opt/memento/.env.docker"
|
||||
touch "$ENV_FILE"
|
||||
sed -i 's/\r$//' "$ENV_FILE"
|
||||
upsert() {
|
||||
local key="$1" val="$2"
|
||||
[ -z "$val" ] && return
|
||||
sed -i "/^[[:space:]]*${key}=/d" "$ENV_FILE"
|
||||
echo "${key}=\"${val}\"" >> "$ENV_FILE"
|
||||
echo "${key}=${val}" >> "$ENV_FILE"
|
||||
}
|
||||
upsert NEXTAUTH_URL "$APP_URL"
|
||||
upsert NEXTAUTH_SECRET "$NEXTAUTH_SECRET"
|
||||
@@ -216,6 +215,8 @@ jobs:
|
||||
upsert SMTP_IGNORE_CERT "$SMTP_IGNORE_CERT"
|
||||
upsert MCP_MODE "$MCP_MODE"
|
||||
upsert MCP_PORT "$MCP_PORT"
|
||||
upsert MCP_SERVER_MODE "$MCP_MODE"
|
||||
upsert MCP_SERVER_URL "${APP_URL}/mcp"
|
||||
upsert WEB_SEARCH_PROVIDER "$WEB_SEARCH_PROVIDER"
|
||||
upsert SEARXNG_URL "$SEARXNG_URL"
|
||||
upsert BRAVE_SEARCH_API_KEY "$BRAVE_SEARCH_API_KEY"
|
||||
|
||||
@@ -67,11 +67,12 @@ jobs:
|
||||
run: |
|
||||
ENV_FILE="/opt/memento/.env.docker"
|
||||
touch "$ENV_FILE"
|
||||
sed -i 's/\r$//' "$ENV_FILE"
|
||||
upsert() {
|
||||
local key="$1" val="$2"
|
||||
[ -z "$val" ] && return
|
||||
sed -i "/^[[:space:]]*${key}=/d" "$ENV_FILE"
|
||||
echo "${key}=\"${val}\"" >> "$ENV_FILE"
|
||||
echo "${key}=${val}" >> "$ENV_FILE"
|
||||
}
|
||||
upsert NEXTAUTH_URL "$APP_URL"
|
||||
upsert NEXTAUTH_SECRET "$NEXTAUTH_SECRET"
|
||||
@@ -102,6 +103,8 @@ jobs:
|
||||
upsert SMTP_IGNORE_CERT "$SMTP_IGNORE_CERT"
|
||||
upsert MCP_MODE "$MCP_MODE"
|
||||
upsert MCP_PORT "$MCP_PORT"
|
||||
upsert MCP_SERVER_MODE "$MCP_MODE"
|
||||
upsert MCP_SERVER_URL "${APP_URL}/mcp"
|
||||
upsert WEB_SEARCH_PROVIDER "$WEB_SEARCH_PROVIDER"
|
||||
upsert SEARXNG_URL "$SEARXNG_URL"
|
||||
upsert BRAVE_SEARCH_API_KEY "$BRAVE_SEARCH_API_KEY"
|
||||
@@ -120,6 +123,7 @@ jobs:
|
||||
upsert GRAFANA_ADMIN_PASSWORD "$GRAFANA_ADMIN_PASSWORD"
|
||||
upsert MCP_API_KEY "$MCP_API_KEY"
|
||||
[ -n "$METRICS_TOKEN" ] && echo "$METRICS_TOKEN" > /opt/memento/monitoring/metrics-token && chmod 600 /opt/memento/monitoring/metrics-token || true
|
||||
echo "env.docker sanity-check passed ($(wc -l < "$ENV_FILE") lines)"
|
||||
|
||||
- name: Deploy (full build, no CI artifact)
|
||||
env:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -50,4 +50,6 @@ docker-data/
|
||||
# Misc
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Monitoring secrets (generate at deploy)
|
||||
monitoring/metrics-token
|
||||
|
||||
26
AGENTS.md
26
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.**
|
||||
- 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.
|
||||
- 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 ; **largeur sidebar responsive** (`components/sidebar.tsx`) — `w-80` (320px) de base, plus large en `xl`/`2xl` (grands écrans) ; garder le fallback `Suspense` de `app/(main)/layout.tsx` **aligné** sur ces largeurs (rail fixe 54px) ; `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.
|
||||
- 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` ; (6) `scripts/deploy-prod.sh` charge `/opt/memento/.env.docker` via un **parseur robuste ligne-par-ligne** (jamais `source` — crash `unexpected EOF` sur guillemet non fermé) et teste Postgres avec `pg_isready` + credentials **du conteneur** (`$POSTGRES_USER` interne) ; (7) lint CI = `eslint --max-warnings 9999` : les **warnings ne bloquent pas**, seules les **erreurs** bloquent (ex. `no-html-link-for-pages` → `<Link>` obligatoire pour la navigation interne, pas `<a href="/">`) ; **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 ; **avant de développer une story, vérifier d'abord dans le code si la feature existe déjà** — les docs de stories (`docs/story-nextgen-editor.md`, `docs/user-stories.md`) sont **souvent périmés** (features livrées mais marquées « à faire »).
|
||||
- **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.
|
||||
- É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` ; **conversion markdown → texte enrichi** : un **seul** convertisseur `lib/markdown-to-html.ts` (`markdownToHtml`, gfm+breaks) + une **action atomique** `convertToRichText(html)` dans `note-editor-context.tsx` (applique le HTML **immédiatement** via `setContentImmediate` — pas le `setContent` débouncé 800ms — et bascule `isMarkdown=false`) ; toolbar (`handleConvertToRichtext`) **et** chat IA contextuel (`contextual-ai-chat.tsx`, action `toRichText` → `/api/ai/convert-markdown`) passent par cette action unique (ne pas dupliquer la conversion ni oublier de basculer `isMarkdown`) ; **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`) ; tracking quotas IA : événement `ai-usage-changed` (window) — doit être dispatché depuis **tous** les points d'appel IA (recherche sémantique, auto-tag, auto-title, chat, reformulation, analyse carnet, etc.) ; UI quotas : `usage-meter.tsx` (polling 5s + écoute événement).
|
||||
- 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) ; **Éditeur Next-Gen livré** (doc `docs/story-nextgen-editor.md` **périmé** — vérifier le code) : US-1 poignée gutter unique (`editor-block-drag-handle.tsx` + `lib/editor/global-drag-handle-extension.ts`, extension Novel `tiptap-extension-global-drag-handle`), US-2 menu action bloc (`block-action-menu.tsx`), US-3 Smart Paste transclusion (`smart-paste-extension.ts` + `smart-paste-menu.tsx`), US-4 database inline (`structuredViewBlock`), plus `data-id` / `liveBlock` / peek split ; **`/insights` livré** : `app/(main)/insights/page.tsx` + `network-graph.tsx` (clusters sémantiques, bridge notes ; **≠ `/graph`**) ; **US-TEMPORAL reporté** ; encore en gap : transclusion bidirectionnelle complète, graphe knowledge enrichi (`GraphKnowledgeMap.tsx`) ; 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 ; **champs DB publication** sur modèle `Note` : `publicSlug` (pas `publishedSlug`), `publishedContent` (pas `publishedHtml`) — `publishMode` est paramètre API uniquement (`mode: 'simple' | 'ai'`), **pas** un champ en base.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Momento Project Rules
|
||||
# Memento Project Rules
|
||||
|
||||
## CRITICAL — DATABASE SAFETY
|
||||
|
||||
|
||||
18
DEPLOY.md
18
DEPLOY.md
@@ -15,7 +15,7 @@
|
||||
```
|
||||
Internet/LAN
|
||||
|
|
||||
[Nginx] ── port 80/443 ── notes.parsanet.org
|
||||
[Nginx] ── port 80/443 ── memento-note.com
|
||||
|
|
||||
├── / ──► [memento-web] ── port 3000 ── Next.js App
|
||||
|
|
||||
@@ -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
|
||||
```
|
||||
@@ -68,7 +68,7 @@ bash scripts/deploy-docker.sh --full
|
||||
|
||||
Le script demande interactivement :
|
||||
- **ADMIN_EMAIL** (obligatoire) - le premier utilisateur inscrit avec cet email obtient le role ADMIN
|
||||
- URL (mettre `http://192.168.1.190` ou `http://notes.parsanet.org`)
|
||||
- URL (mettre `http://192.168.1.190` ou `https://memento-note.com`)
|
||||
- Provider AI + cle API
|
||||
- MCP server, email, recherche web (optionnels)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -233,7 +233,7 @@ apt install -y nginx
|
||||
cat > /etc/nginx/sites-available/memento << 'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name notes.parsanet.org 192.168.1.190;
|
||||
server_name memento-note.com 192.168.1.190;
|
||||
|
||||
# Next.js app
|
||||
location / {
|
||||
@@ -274,12 +274,12 @@ nginx -t && systemctl reload nginx
|
||||
|
||||
```bash
|
||||
apt install -y certbot python3-certbot-nginx
|
||||
certbot --nginx -d notes.parsanet.org
|
||||
certbot --nginx -d memento-note.com
|
||||
|
||||
# Puis mettre a jour NEXTAUTH_URL :
|
||||
cd /opt/memento
|
||||
nano .env.docker
|
||||
# Changer : NEXTAUTH_URL="https://notes.parsanet.org"
|
||||
# Changer : NEXTAUTH_URL="https://memento-note.com"
|
||||
docker compose restart memento-note
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Deferred Work
|
||||
|
||||
## Deferred from: code review of 3-5-secure-byok-management (2026-05-16)
|
||||
## ~~Deferred from: code review of 3-5-secure-byok-management (2026-05-16)~~ ✅ COMPLETED
|
||||
|
||||
- **Test host BYOK + quota invité vide (Task 7.4)** — Scénario AC10 (hôte BYOK, quota invité vide) non couvert par test dédié dans `brainstorm-billing.test.ts`.
|
||||
- **`lastUsedAt` / `lastUsedFor` jamais mis à jour** — Champs Prisma présents mais non alimentés à l’usage des clés BYOK.
|
||||
- **`keyHash` non utilisé pour dédup** — Hash SHA-256 stocké sans logique de déduplication à l’upsert.
|
||||
- **Downgrade tier → désactivation clés hors liste** — Pas de `isActive=false` automatique au downgrade PRO/Business ; seul le rejet des nouveaux saves est en place.
|
||||
- **Rate limit POST `/api/user/api-keys`** — Pas de limite Redis documentée en spec optionnelle.
|
||||
All BYOK items implemented on 2026-05-30:
|
||||
- ✅ **Test host BYOK + quota invité vide (Task 7.4)** — Test AC10 ajouté dans `brainstorm-billing.test.ts`
|
||||
- ✅ **`lastUsedAt` / `lastUsedFor` tracking** — Implémenté dans `resolveByokApiKey()` avec update async non-bloquant
|
||||
- ✅ **`keyHash` dédup cross-provider** — `findDuplicateApiKeyHash()` ajouté, vérification dans route POST (409 Conflict)
|
||||
- ✅ **Downgrade tier → désactivation clés** — `deactivateUnauthorizedKeys()` + safety check dans `getActiveByokKey()`
|
||||
- ✅ **Rate limit POST `/api/user/api-keys`** — `checkApiKeyCreationRateLimit()` (5/h) dans route POST (429)
|
||||
|
||||
## Deferred from: code review of 4-1-gdpr-cookie-consent (2026-05-16)
|
||||
|
||||
@@ -14,7 +15,7 @@
|
||||
|
||||
## Deferred from: unified tasks view study (2026-05-24)
|
||||
|
||||
- **Vue agrégée Notes/Tâches (Markdown scrape)** — Retirée volontairement (option A produit) : surcharge UX, chevauchement avec vues structurées Kanban. Spec `spec-unified-tasks-view.md` abandonnée ; pas d’unification TipTap/Checklist pour cette vue.
|
||||
- **Vue agrégée Notes/Tâches (Markdown scrape)** — Retirée volontairement (option A produit) : surcharge UX, chevauchement avec vues structurées Kanban. Spec `spec-unified-tasks-view.md` abandonnée ; pas d'unification TipTap/Checklist pour cette vue.
|
||||
|
||||
## Deferred from: US-TEMPORAL product decision (2026-05-24)
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: 'Cross-Project Health Audit'
|
||||
type: 'chore'
|
||||
created: '2026-06-20'
|
||||
status: 'done'
|
||||
baseline_commit: '40f30155c2a0f118ca41c8226992035bcab55a46'
|
||||
context:
|
||||
- '{project-root}/AGENTS.md'
|
||||
- '{project-root}/CLAUDE.md'
|
||||
- '{project-root}/memento-note/docs/admin-billing-quotas-guide.md'
|
||||
---
|
||||
|
||||
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||
|
||||
## Intent
|
||||
|
||||
**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.
|
||||
|
||||
## Boundaries & Constraints
|
||||
|
||||
**Always:** No destructive DB commands; backup via `dump-db.sh` before any schema change; i18n via 15 locales when touching UI; follow prototype `architectural-grid` for UX gaps; fail-closed for billing quotas in production.
|
||||
|
||||
**Ask First:** Whether to fail-open vs fail-closed quotas during Redis outage; whether MCP `x-user-id` should be removed entirely or restricted to internal network; Chrome Web Store host_permissions scope; archiving `architectural-grid1` vs keeping both prototypes.
|
||||
|
||||
**Never:** `prisma migrate reset`, `db push --force-reset`, `pg_dump --clean` in deploy scripts; exposing API keys in client bundles; shipping extension with dev `host_permissions`; implementing all audit items in one PR.
|
||||
|
||||
</frozen-after-approval>
|
||||
|
||||
## Code Map
|
||||
|
||||
- `memento-note/app/api/uploads/[...path]/route.ts` -- uploads served without session check
|
||||
- `memento-note/app/api/image-proxy/route.ts` -- open SSRF proxy
|
||||
- `memento-note/lib/entitlements.ts` -- Redis fail-open on AI quotas
|
||||
- `memento-note/auth.config.ts` -- `/insights`, `/graph`, `/revision`, `/support` not gated
|
||||
- `memento-note/context/notebooks-context.tsx` -- broken AI toast note move (no home refresh)
|
||||
- `mcp-server/index-sse.js` -- `x-user-id` header auth bypass
|
||||
- `mcp-server/tools.js` -- missing userId filter + `ensureUserId()` first-user fallback
|
||||
- `memento-note/extension/dist-chrome-store/` -- missing PNG icons, wide host_permissions
|
||||
- `memento-mobile/app.json` -- references missing `assets/`
|
||||
- `monitoring/metrics-token` -- placeholder secret committed
|
||||
- `scripts/deploy-prod.sh` -- `pg_dump --clean`, full Docker build fallback
|
||||
- `docker-compose.yml` -- MCP/Ollama ports bound 0.0.0.0
|
||||
|
||||
## Tasks & Acceptance
|
||||
|
||||
**Execution (Phase 1 — P0 security only, if approved):**
|
||||
- [x] `memento-note/app/api/uploads/[...path]/route.ts` -- require auth + verify note ownership for file path -- prevent private attachment leak
|
||||
- [x] `memento-note/app/api/image-proxy/route.ts` -- require auth + domain allowlist -- close SSRF/abuse
|
||||
- [x] `memento-note/lib/entitlements.ts` -- fail-closed when Redis unavailable in production -- align with billing policy
|
||||
- [x] `memento-note/auth.config.ts` -- add `/insights`, `/graph`, `/revision`, `/support` to protected routes -- match API 401 behavior
|
||||
- [x] `mcp-server/index-sse.js` -- remove or restrict `x-user-id`; require API key on all tool paths -- close identity spoofing
|
||||
- [x] `mcp-server/tools.js` -- always scope queries to authenticated userId; remove `ensureUserId()` fallback -- multi-tenant isolation
|
||||
- [x] `docker-compose.yml` -- bind MCP `127.0.0.1:3001:3001` -- reduce network exposure
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Given an unauthenticated request, when GET `/api/uploads/notes/{uuid}.png`, then response is 401/403
|
||||
- Given Redis down in production, when AI quota check runs, then request is denied (not unlimited)
|
||||
- Given unauthenticated browser, when navigating to `/insights`, then redirect to login
|
||||
- Given MCP request with only `x-user-id` header and no API key, when tool invoked, then 401
|
||||
- Given authenticated user A, when MCP lists notes, then only user A notes returned
|
||||
|
||||
## Spec Change Log
|
||||
|
||||
## Design Notes
|
||||
|
||||
Full audit by project (2026-06-20):
|
||||
|
||||
**memento-note CRITICAL:** open uploads; image-proxy SSRF; fail-open quotas; middleware gaps; XSS via `dangerouslySetInnerHTML` (published pages, peek, SVG gallery); weak mobile HMAC; broken AI toast note move.
|
||||
|
||||
**mcp-server CRITICAL:** `x-user-id` spoofing; null userId returns all users' data; port 3001 public; Prisma schema drift vs main app.
|
||||
|
||||
**extension CRITICAL:** Store build missing PNG icons; `host_permissions` too broad for CWS.
|
||||
|
||||
**mobile CRITICAL:** missing assets folder; NativeWind configured but not installed.
|
||||
|
||||
**monitoring CRITICAL:** placeholder metrics token in git; Grafana on 0.0.0.0:3002.
|
||||
|
||||
**CI/CD CRITICAL:** manual deploy triggers full Docker build (fails on prod); `pg_dump --clean` in deploy script.
|
||||
|
||||
**prototypes MEDIUM:** Gemini API key in Vite client bundle; port 3000 conflict; grid vs grid1 divergence.
|
||||
|
||||
**Deferred phases:** P1 insights i18n + GraphKnowledgeMap; P2 editor drag-handle migration; P3 mobile EAS/CI; P4 monitoring alert cleanup.
|
||||
|
||||
## Verification
|
||||
|
||||
**Commands:**
|
||||
- `cd memento-note && npm run test:unit` -- expected: pass (no regressions on touched modules)
|
||||
- Manual curl unauthenticated upload URL -- expected: 401/403 after Phase 1
|
||||
|
||||
**Manual checks:**
|
||||
- Logged-out visit to `/insights` redirects to login
|
||||
- MCP tool call without API key returns 401
|
||||
|
||||
## Suggested Review Order
|
||||
|
||||
**Upload access control**
|
||||
|
||||
- Ownership check before serving note attachments; published notes stay public
|
||||
[`route.ts:30`](../../memento-note/app/api/uploads/[...path]/route.ts#L30)
|
||||
|
||||
- Prisma lookup tying filename to note content/images
|
||||
[`upload-access.ts:4`](../../memento-note/lib/upload-access.ts#L4)
|
||||
|
||||
**SSRF / image proxy**
|
||||
|
||||
- Session gate + blocked private hosts on server-side fetch
|
||||
[`route.ts:6`](../../memento-note/app/api/image-proxy/route.ts#L6)
|
||||
|
||||
- Shared SSRF hostname denylist
|
||||
[`ssrf-guard.ts:2`](../../memento-note/lib/ssrf-guard.ts#L2)
|
||||
|
||||
**Billing quotas fail-closed**
|
||||
|
||||
- Production denies when Redis is unavailable
|
||||
[`entitlements.ts:88`](../../memento-note/lib/entitlements.ts#L88)
|
||||
|
||||
- Routes return 503 instead of silent fail-open
|
||||
[`route.ts:81`](../../memento-note/app/api/chat/route.ts#L81)
|
||||
|
||||
**Auth middleware**
|
||||
|
||||
- Protect insights, graph, revision, support routes
|
||||
[`auth.config.ts:30`](../../memento-note/auth.config.ts#L30)
|
||||
|
||||
**MCP multi-tenant isolation**
|
||||
|
||||
- API key only; x-user-id removed
|
||||
[`index-sse.js:291`](../../mcp-server/index-sse.js#L291)
|
||||
|
||||
- Mandatory userId on all tool handlers
|
||||
[`tools.js:490`](../../mcp-server/tools.js#L490)
|
||||
|
||||
**Infrastructure**
|
||||
|
||||
- MCP port bound to localhost only
|
||||
[`docker-compose.yml:130`](../../docker-compose.yml#L130)
|
||||
|
||||
**Tests**
|
||||
|
||||
- Fail-open dev vs fail-closed prod entitlements
|
||||
[`entitlements.test.ts:145`](../../memento-note/tests/unit/entitlements.test.ts#L145)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
status: in-progress
|
||||
title: "GDPR Analytics Sync + Error Reporting Hardening"
|
||||
story: 4.1-gdpr-analytics-sync
|
||||
epic: 4
|
||||
priority: high
|
||||
blast_radius: medium
|
||||
---
|
||||
|
||||
# Spec: GDPR Analytics Sync + Error Reporting Hardening
|
||||
|
||||
## Context
|
||||
|
||||
Deferred work from Story 4.1 (GDPR Cookie Consent):
|
||||
- AC5 anonymousAnalytics DB sync was not implemented
|
||||
- Original constraint: "zero DB writes in 4.1, 100% client consent"
|
||||
- Now removing constraint to implement proper sync
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Primary**: Sync cookie consent `anonymousAnalytics` to database for authenticated users
|
||||
2. **Secondary**: Verify error reporting is properly wired (legitimate interest, no consent needed)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### AC1: Authenticated user consent syncs to DB
|
||||
- When a logged-in user accepts/rejects analytics via banner or preferences, `UserAISettings.anonymousAnalytics` is updated in DB
|
||||
- Guest users (no session) continue using localStorage only
|
||||
|
||||
### AC2: Cross-device consistency
|
||||
- User's analytics consent persists across devices when logged in
|
||||
- Initial consent load prefers DB value when local storage is empty
|
||||
|
||||
### AC3: Error reporting verification
|
||||
- Existing error reporting continues to work without consent (legitimate interest)
|
||||
- Verify `/api/debug/client-error` route exists and is wired
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Task 1: Create server action for consent sync
|
||||
- [ ] Task 2: Update cookie consent utilities
|
||||
- [ ] Task 3: Update banner and dialog components
|
||||
- [ ] Task 4: Verify error reporting route
|
||||
|
||||
## Code Map
|
||||
|
||||
### New/Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `lib/consent/cookie-consent.ts` | Add `saveConsentWithSync()` client wrapper |
|
||||
| `app/actions/cookie-consent.ts` | NEW - Server action for DB sync |
|
||||
| `components/legal/cookie-consent-banner.tsx` | Use new sync action |
|
||||
| `components/legal/cookie-preferences-dialog.tsx` | Use new sync action |
|
||||
| `app/api/debug/client-error/route.ts` | Verify exists |
|
||||
|
||||
### Design Notes
|
||||
|
||||
- Server action calls `updateAISettings({ anonymousAnalytics: boolean })`
|
||||
- Client-side: combine `setConsent()` + server action in parallel
|
||||
- Server action silently succeeds for guests (no session) — component ignores result
|
||||
- Error reporting uses `/api/debug/client-error` — already exists, no consent gate needed
|
||||
|
||||
## Spec Change Log
|
||||
|
||||
- 2026-05-30: Created spec for deferred AC5 implementation
|
||||
- 2026-05-30: Added error reporting verification as secondary goal
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.8
|
||||
|
||||
### Completion Notes List
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: 'Subscription & Quota Admin Management'
|
||||
type: 'feature'
|
||||
created: '2026-06-20'
|
||||
status: 'done'
|
||||
baseline_commit: '5b13a88b726c03ca6ff5603d901147448a72adbc'
|
||||
context:
|
||||
- 'memento-note/lib/entitlements.ts'
|
||||
- 'memento-note/lib/config.ts'
|
||||
- 'memento-note/lib/billing/stripe-prices.ts'
|
||||
---
|
||||
|
||||
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||
|
||||
## Intent
|
||||
|
||||
**Problem:** Subscription tiers, AI quotas, and Stripe business config are split between hardcoded code (`TIER_LIMITS`), server `.env` (price IDs, billing flag), and a minimal admin override (tier dropdown only). Operators cannot adjust limits or billing settings without redeploy; quota enforcement has a TOCTOU race (`check` then `increment`); token analytics are partially broken.
|
||||
|
||||
**Approach:** Add a secure admin **Billing & Quotas** console backed by DB (`PlanEntitlement` + `SystemConfig`), keep Stripe secrets in infra env only, unify quota enforcement on atomic `reserveUsageOrThrow`, and expose usage dashboards with audit logging for manual tier overrides.
|
||||
|
||||
## Boundaries & Constraints
|
||||
|
||||
**Always:**
|
||||
- `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` stay in server env / secrets vault — never in `SystemConfig`, never editable from browser.
|
||||
- Stripe remains authoritative for paid subscriptions; webhooks drive state; manual admin tier changes are support overrides only.
|
||||
- Quota gating unit stays **requests per feature per calendar month** (not token-based blocking).
|
||||
- Redis quota checks fail-open with structured error logging (industry standard for AI rate limits).
|
||||
- All admin mutations require `role === ADMIN`; i18n for new UI strings in `locales/en.json` and `locales/fr.json` minimum.
|
||||
- Non-destructive DB migration only; seed `PlanEntitlement` from current `TIER_LIMITS` defaults.
|
||||
|
||||
**Ask First:**
|
||||
- Adding new Prisma models beyond `PlanEntitlement` (e.g. full `FeatureFlag` wiring).
|
||||
- Changing Stripe products/prices in live Stripe Dashboard (ops action, not code).
|
||||
- Switching to usage-based Stripe Meter / Metronome billing.
|
||||
|
||||
**Never:**
|
||||
- Store `sk_*` or `whsec_*` in plaintext DB or admin forms.
|
||||
- Run `prisma migrate reset`, `db push --accept-data-loss`, or any destructive DB command.
|
||||
- Replace Redis real-time counters with synchronous PostgreSQL writes on every AI request.
|
||||
- Add automated tests unless explicitly requested later.
|
||||
|
||||
## I/O & Edge-Case Matrix
|
||||
|
||||
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|
||||
|----------|--------------|---------------------------|----------------|
|
||||
| Admin saves tier limit | ADMIN edits PRO `chat` limit 50→75 | `PlanEntitlement` upserted; next `getLimit()` returns 75; existing Redis counters unchanged until period rollover | 403 if non-admin; 400 if invalid feature or negative limit |
|
||||
| Admin saves Stripe price ID | ADMIN sets `STRIPE_PRICE_PRO_MONTHLY` in SystemConfig | `resolvePriceId('PRO','month')` reads DB value; env used only as fallback | 400 if empty when billing enabled |
|
||||
| User at quota | User on BASIC, 30/30 `semantic_search` used | `reserveUsageOrThrow` returns 402 `QUOTA_EXCEEDED` | Fail-open if Redis down (allow + log alert) |
|
||||
| Concurrent AI requests | Two requests at limit−1 | Only one succeeds; second gets 402 (atomic Lua) | N/A |
|
||||
| Admin manual tier override | ADMIN sets user to BUSINESS | `Subscription` upserted; `AuditLog` entry with actor, target, old/new tier | Cannot override own tier (existing rule) |
|
||||
| Billing disabled | `BILLING_ENABLED=false` in SystemConfig | `/settings/billing` shows disabled state; checkout buttons hidden | N/A |
|
||||
| suggest-charts usage | Successful chart suggestion | `incrementUsageAsync` called once (not broken 4-arg call) | N/A |
|
||||
|
||||
</frozen-after-approval>
|
||||
|
||||
## Code Map
|
||||
|
||||
- `memento-note/prisma/schema.prisma` — add `PlanEntitlement` model (`tier`, `feature`, `limitValue`; unique on `[tier, feature]`; `null` limitValue = unlimited; omit row or sentinel = feature unavailable per tier)
|
||||
- `memento-note/prisma/migrations/*` — additive migration + seed from current `TIER_LIMITS`
|
||||
- `memento-note/lib/entitlements.ts` — load limits from DB with in-memory cache (TTL ~60s) and hardcoded fallback; export `getLimit()` async; keep fail-open on Redis errors
|
||||
- `memento-note/lib/billing/stripe-prices.ts` — read price IDs via `getConfigValue()` with env fallback; remove sole dependence on `process.env`
|
||||
- `memento-note/lib/config.ts` — add billing keys to `ENV_FALLBACKS` (`BILLING_ENABLED`, four `STRIPE_PRICE_*`)
|
||||
- `memento-note/app/actions/admin-billing.ts` — server actions: `getPlanEntitlements`, `updatePlanEntitlement`, `updateBillingConfig`, `getUsageOverview`
|
||||
- `memento-note/app/(admin)/admin/billing/page.tsx` — admin UI: tier limits matrix, billing config, usage summary table
|
||||
- `memento-note/app/(admin)/admin/billing/billing-admin-form.tsx` — client form component
|
||||
- `memento-note/components/admin-sidebar.tsx` — nav link `/admin/billing`
|
||||
- `memento-note/app/actions/admin.ts` — log `SUBSCRIPTION_OVERRIDE` audit on `updateUserSubscription`
|
||||
- `memento-note/lib/audit-log.ts` — extend `AuditAction` with `SUBSCRIPTION_OVERRIDE`, `BILLING_CONFIG_UPDATED`, `PLAN_ENTITLEMENT_UPDATED`
|
||||
- `memento-note/components/settings/billing-plans.tsx` — read `billingEnabled` from `/api/billing/status` instead of build-time env only
|
||||
- `memento-note/app/api/billing/status/route.ts` — include `billingEnabled` from SystemConfig
|
||||
- `memento-note/app/api/ai/suggest-charts/route.ts` — fix usage tracking (use `incrementUsageAsync`, not broken `trackFeatureUsage` call)
|
||||
- AI routes using `checkEntitlementOrThrow` + `incrementUsageAsync` — migrate to `reserveUsageOrThrow` only (remove duplicate increment on success); include chat route
|
||||
|
||||
## Tasks & Acceptance
|
||||
|
||||
**Execution:**
|
||||
- [x] `prisma/schema.prisma` + migration — add `PlanEntitlement`, seed defaults from `TIER_LIMITS` — DB-backed limits without breaking existing users
|
||||
- [x] `lib/entitlements.ts` — async limit loader with cache + fallback; update `canUseFeature`, `reserveUsageOrThrow`, `getUserQuotas` — single source of truth
|
||||
- [x] `lib/billing/stripe-prices.ts` + `lib/config.ts` — SystemConfig-backed price IDs and `BILLING_ENABLED` — admin-editable business config
|
||||
- [x] `app/actions/admin-billing.ts` — CRUD entitlements + billing config + usage overview query (join `UsageLog` + Redis snapshot) — secure admin API surface
|
||||
- [x] `app/(admin)/admin/billing/*` + `components/admin-sidebar.tsx` — admin UI section — operator-facing management
|
||||
- [x] `app/actions/admin.ts` + `lib/audit-log.ts` — audit trail for tier overrides and config changes — support accountability
|
||||
- [x] AI routes (grep `checkEntitlementOrThrow`) + `app/api/chat/route.ts` — switch to `reserveUsageOrThrow`; drop redundant `incrementUsageAsync` on success — fix TOCTOU race
|
||||
- [x] `app/api/ai/suggest-charts/route.ts` — fix broken usage call — restore quota accounting
|
||||
- [x] `app/api/billing/status/route.ts` + `components/settings/billing-plans.tsx` — runtime billing flag — no redeploy to toggle billing UI
|
||||
- [x] `locales/en.json` + `locales/fr.json` — i18n keys under `admin.billing.*` — FR/EN reference labels
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Given an ADMIN on `/admin/billing`, when they change PRO `chat` limit and save, then a new entitlement check within 60s reflects the new limit without app restart.
|
||||
- Given a BASIC user at quota, when two parallel AI requests arrive, then at most one succeeds and the other returns HTTP 402.
|
||||
- Given `BILLING_ENABLED` is false in SystemConfig, when a user opens `/settings/billing`, then checkout/upgrade actions are hidden and a clear disabled message is shown.
|
||||
- Given an ADMIN changes a user's tier from `/admin/users`, when the change succeeds, then an `AuditLog` row records actor, target user, old tier, and new tier.
|
||||
- Given Stripe price IDs are set in SystemConfig (env unset), when checkout is initiated, then the correct Stripe price is used.
|
||||
- Given Redis is unreachable, when an AI request is made, then the request is allowed (fail-open) and an error is logged server-side.
|
||||
- Given `STRIPE_SECRET_KEY` remains env-only, when inspecting `SystemConfig` table, then no `sk_` or `whsec_` values exist.
|
||||
|
||||
## Design Notes
|
||||
|
||||
**PlanEntitlement limit encoding:** `limitValue: null` → unlimited; positive integer → monthly request cap; omit row (or explicit `-1` sentinel if needed) → feature not available on tier (maps to current `undefined` in `TIER_LIMITS`).
|
||||
|
||||
**Cache invalidation:** After admin saves entitlements, call a small `invalidateEntitlementCache()` so changes apply immediately without waiting for TTL.
|
||||
|
||||
**Quota migration pattern:** Replace `await checkEntitlementOrThrow(userId, feature)` + `incrementUsageAsync(...)` with `await reserveUsageOrThrow(userId, feature)` at request start. Do not increment again on success. For streaming chat, reserve before `streamText`; no post-finish increment.
|
||||
|
||||
**Usage dashboard:** Show per-tier aggregate from `UsageLog` (last synced month) plus optional live Redis read for current month top features — degraded state if sync cron stale is acceptable (show `syncedAt`).
|
||||
|
||||
## Verification
|
||||
|
||||
**Commands:**
|
||||
- `cd memento-note && npm run test:unit -- tests/unit/entitlements.test.ts` — expected: pass after async limit loader changes
|
||||
- `cd memento-note && npx tsc --noEmit` — expected: no type errors
|
||||
|
||||
**Manual checks:**
|
||||
- ADMIN `/admin/billing`: edit a limit, save, verify `/api/usage/current` reflects new cap for a test user on that tier.
|
||||
- Non-admin GET to admin-billing server actions returns unauthorized.
|
||||
- Two rapid AI calls at quota−1: only one succeeds.
|
||||
|
||||
## Spec Change Log
|
||||
|
||||
## Suggested Review Order
|
||||
|
||||
**Entitlements & DB limits**
|
||||
|
||||
- Central loader: DB `PlanEntitlement` with 60s cache and hardcoded fallback
|
||||
[`plan-entitlements.ts:1`](../../memento-note/lib/plan-entitlements.ts#L1)
|
||||
|
||||
- Prisma model + seeded migration from former `TIER_LIMITS`
|
||||
[`schema.prisma:814`](../../memento-note/prisma/schema.prisma#L814)
|
||||
|
||||
- Entitlements API now reads limits asynchronously from cache
|
||||
[`entitlements.ts:1`](../../memento-note/lib/entitlements.ts#L1)
|
||||
|
||||
**Admin console**
|
||||
|
||||
- Server actions: entitlements CRUD, billing config, usage overview
|
||||
[`admin-billing.ts:1`](../../memento-note/app/actions/admin-billing.ts#L1)
|
||||
|
||||
- Admin UI at `/admin/billing` with tier matrix + Stripe price IDs
|
||||
[`billing-admin-client.tsx:1`](../../memento-note/app/(admin)/admin/billing/billing-admin-client.tsx#L1)
|
||||
|
||||
- Audit trail on manual tier override
|
||||
[`admin.ts:142`](../../memento-note/app/actions/admin.ts#L142)
|
||||
|
||||
**Stripe & billing UX**
|
||||
|
||||
- Price IDs and `BILLING_ENABLED` via SystemConfig (env fallback)
|
||||
[`stripe-prices.ts:79`](../../memento-note/lib/billing/stripe-prices.ts#L79)
|
||||
|
||||
- Runtime billing flag exposed to settings page
|
||||
[`billing-plans.tsx:80`](../../memento-note/components/settings/billing-plans.tsx#L80)
|
||||
|
||||
**Quota hardening**
|
||||
|
||||
- Atomic `reserveUsageOrThrow` on billable AI routes (replaces check+increment)
|
||||
[`chat/route.ts:69`](../../memento-note/app/api/chat/route.ts#L69)
|
||||
|
||||
- Fixed broken suggest-charts usage tracking
|
||||
[`suggest-charts/route.ts:58`](../../memento-note/app/api/ai/suggest-charts/route.ts#L58)
|
||||
|
||||
**Tests**
|
||||
|
||||
- Entitlements + billing price map unit tests updated
|
||||
[`entitlements.test.ts:1`](../../memento-note/tests/unit/entitlements.test.ts#L1)
|
||||
@@ -2,10 +2,18 @@
|
||||
title: 'US-4 Redesign — Embedded Structured View Block (replaces authors/works demo)'
|
||||
type: 'refactor'
|
||||
created: '2026-05-27'
|
||||
status: 'draft'
|
||||
status: 'done'
|
||||
baseline_commit: 'CURRENT'
|
||||
completedDate: '2026-05-30'
|
||||
context:
|
||||
- 'docs/story-nextgen-editor-us4-redesign.md'
|
||||
- 'memento-note/lib/structured-views/types.ts'
|
||||
note: >
|
||||
Implementation complete with TWO modes:
|
||||
1. Local Database (isLocal: true) — Notion-like inline table with editable columns/rows
|
||||
2. Notebook Linked View (isLocal: false) — Live Structured Views integration
|
||||
Plus: Analytics panel, Memory Echo semantic search, Convert-to-notebook feature.
|
||||
Implementation exceeds original spec scope.
|
||||
---
|
||||
|
||||
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
|
||||
@@ -90,7 +98,7 @@ context:
|
||||
|
||||
## 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"
|
||||
|
||||
@@ -36,7 +36,7 @@ async function startServer() {
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
const PORT = 3000;
|
||||
const PORT = 4000;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
@@ -170,6 +170,38 @@ async function startServer() {
|
||||
res.json(ideas[index]);
|
||||
});
|
||||
|
||||
app.post('/api/gemini/embed', async (req, res) => {
|
||||
const key = process.env.GEMINI_API_KEY;
|
||||
if (!key) {
|
||||
return res.status(503).json({ error: 'GEMINI_API_KEY not configured on server' });
|
||||
}
|
||||
try {
|
||||
const { GoogleGenAI } = await import('@google/genai');
|
||||
const ai = new GoogleGenAI({ apiKey: key });
|
||||
const response = await ai.models.embedContent(req.body);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Gemini embed error:', error);
|
||||
res.status(500).json({ error: 'Gemini embed failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gemini/generate', async (req, res) => {
|
||||
const key = process.env.GEMINI_API_KEY;
|
||||
if (!key) {
|
||||
return res.status(503).json({ error: 'GEMINI_API_KEY not configured on server' });
|
||||
}
|
||||
try {
|
||||
const { GoogleGenAI } = await import('@google/genai');
|
||||
const ai = new GoogleGenAI({ apiKey: key });
|
||||
const response = await ai.models.generateContent(req.body);
|
||||
res.json({ text: response.text ?? '' });
|
||||
} catch (error) {
|
||||
console.error('Gemini proxy error:', error);
|
||||
res.status(500).json({ error: 'Gemini request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Vite middleware for development
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const vite = await createViteServer({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,24 @@
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { BrainstormIdea } from "../types";
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
async function generateContent(
|
||||
request: Parameters<InstanceType<typeof GoogleGenAI>['models']['generateContent']>[0],
|
||||
) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const res = await fetch('/api/gemini/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('Gemini request failed');
|
||||
const data = await res.json();
|
||||
return { text: data.text ?? '' };
|
||||
}
|
||||
const key = process.env.GEMINI_API_KEY;
|
||||
if (!key) throw new Error('GEMINI_API_KEY required on server');
|
||||
const ai = new GoogleGenAI({ apiKey: key });
|
||||
return ai.models.generateContent(request);
|
||||
}
|
||||
|
||||
const BRAINSTORM_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
@@ -63,7 +80,7 @@ export async function generateBrainstormWave(
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
@@ -101,7 +118,7 @@ export async function generateExpansion(parentIdeaTitle: string, parentIdeaDescr
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
@@ -130,9 +147,27 @@ export async function generateExpansion(parentIdeaTitle: string, parentIdeaDescr
|
||||
}
|
||||
}
|
||||
|
||||
async function embedContent(
|
||||
request: Parameters<InstanceType<typeof GoogleGenAI>['models']['embedContent']>[0],
|
||||
) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const res = await fetch('/api/gemini/embed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('Gemini embed failed');
|
||||
return res.json();
|
||||
}
|
||||
const key = process.env.GEMINI_API_KEY;
|
||||
if (!key) throw new Error('GEMINI_API_KEY required on server');
|
||||
const ai = new GoogleGenAI({ apiKey: key });
|
||||
return ai.models.embedContent(request);
|
||||
}
|
||||
|
||||
export async function getEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
const result = await ai.models.embedContent({
|
||||
const result = await embedContent({
|
||||
model: 'gemini-embedding-2-preview',
|
||||
contents: [text],
|
||||
});
|
||||
@@ -155,7 +190,7 @@ export function cosineSimilarity(a: number[], b: number[]): number {
|
||||
export async function nameCluster(noteSummaries: string[]): Promise<string> {
|
||||
const prompt = `Quel thème commun relie ces notes ? Donne un nom court (2-4 mots).\nNotes :\n${noteSummaries.join('\n- ')}`;
|
||||
try {
|
||||
const result = await ai.models.generateContent({
|
||||
const result = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt
|
||||
});
|
||||
@@ -184,7 +219,7 @@ export async function suggestBridgeIdeas(
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt,
|
||||
config: {
|
||||
@@ -207,7 +242,7 @@ export async function parseDocument(fileUrl: string, fileName: string): Promise<
|
||||
try {
|
||||
// In a real scenario, we would use media upload.
|
||||
// Here we simulate the extraction.
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
@@ -236,7 +271,7 @@ export async function extractActionItems(notes: { title: string; content: string
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt,
|
||||
config: {
|
||||
@@ -289,7 +324,7 @@ export async function generateFlashcardsForNote(
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3.5-flash",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig, loadEnv} from 'vite';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
|
||||
@@ -36,7 +36,7 @@ async function startServer() {
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
const PORT = 3000;
|
||||
const PORT = 4000;
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
@@ -170,6 +170,38 @@ async function startServer() {
|
||||
res.json(ideas[index]);
|
||||
});
|
||||
|
||||
app.post('/api/gemini/embed', async (req, res) => {
|
||||
const key = process.env.GEMINI_API_KEY;
|
||||
if (!key) {
|
||||
return res.status(503).json({ error: 'GEMINI_API_KEY not configured on server' });
|
||||
}
|
||||
try {
|
||||
const { GoogleGenAI } = await import('@google/genai');
|
||||
const ai = new GoogleGenAI({ apiKey: key });
|
||||
const response = await ai.models.embedContent(req.body);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Gemini embed error:', error);
|
||||
res.status(500).json({ error: 'Gemini embed failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/gemini/generate', async (req, res) => {
|
||||
const key = process.env.GEMINI_API_KEY;
|
||||
if (!key) {
|
||||
return res.status(503).json({ error: 'GEMINI_API_KEY not configured on server' });
|
||||
}
|
||||
try {
|
||||
const { GoogleGenAI } = await import('@google/genai');
|
||||
const ai = new GoogleGenAI({ apiKey: key });
|
||||
const response = await ai.models.generateContent(req.body);
|
||||
res.json({ text: response.text ?? '' });
|
||||
} catch (error) {
|
||||
console.error('Gemini proxy error:', error);
|
||||
res.status(500).json({ error: 'Gemini request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Vite middleware for development
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const vite = await createViteServer({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,24 @@
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import { BrainstormIdea } from "../types";
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
async function generateContent(
|
||||
request: Parameters<InstanceType<typeof GoogleGenAI>['models']['generateContent']>[0],
|
||||
) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const res = await fetch('/api/gemini/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('Gemini request failed');
|
||||
const data = await res.json();
|
||||
return { text: data.text ?? '' };
|
||||
}
|
||||
const key = process.env.GEMINI_API_KEY;
|
||||
if (!key) throw new Error('GEMINI_API_KEY required on server');
|
||||
const ai = new GoogleGenAI({ apiKey: key });
|
||||
return ai.models.generateContent(request);
|
||||
}
|
||||
|
||||
const BRAINSTORM_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
@@ -63,7 +80,7 @@ export async function generateBrainstormWave(
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
@@ -101,7 +118,7 @@ export async function generateExpansion(parentIdeaTitle: string, parentIdeaDescr
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
@@ -130,9 +147,27 @@ export async function generateExpansion(parentIdeaTitle: string, parentIdeaDescr
|
||||
}
|
||||
}
|
||||
|
||||
async function embedContent(
|
||||
request: Parameters<InstanceType<typeof GoogleGenAI>['models']['embedContent']>[0],
|
||||
) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const res = await fetch('/api/gemini/embed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
if (!res.ok) throw new Error('Gemini embed failed');
|
||||
return res.json();
|
||||
}
|
||||
const key = process.env.GEMINI_API_KEY;
|
||||
if (!key) throw new Error('GEMINI_API_KEY required on server');
|
||||
const ai = new GoogleGenAI({ apiKey: key });
|
||||
return ai.models.embedContent(request);
|
||||
}
|
||||
|
||||
export async function getEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
const result = await ai.models.embedContent({
|
||||
const result = await embedContent({
|
||||
model: 'gemini-embedding-2-preview',
|
||||
contents: [text],
|
||||
});
|
||||
@@ -155,7 +190,7 @@ export function cosineSimilarity(a: number[], b: number[]): number {
|
||||
export async function nameCluster(noteSummaries: string[]): Promise<string> {
|
||||
const prompt = `Quel thème commun relie ces notes ? Donne un nom court (2-4 mots).\nNotes :\n${noteSummaries.join('\n- ')}`;
|
||||
try {
|
||||
const result = await ai.models.generateContent({
|
||||
const result = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt
|
||||
});
|
||||
@@ -184,7 +219,7 @@ export async function suggestBridgeIdeas(
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt,
|
||||
config: {
|
||||
@@ -207,7 +242,7 @@ export async function parseDocument(fileUrl: string, fileName: string): Promise<
|
||||
try {
|
||||
// In a real scenario, we would use media upload.
|
||||
// Here we simulate the extraction.
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
@@ -236,7 +271,7 @@ export async function extractActionItems(notes: { title: string; content: string
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3-flash-preview",
|
||||
contents: prompt,
|
||||
config: {
|
||||
@@ -289,7 +324,7 @@ export async function generateFlashcardsForNote(
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
const response = await generateContent({
|
||||
model: "gemini-3.5-flash",
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
config: {
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig, loadEnv} from 'vite';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
define: {
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
|
||||
@@ -127,7 +127,7 @@ services:
|
||||
- .env.docker
|
||||
ports:
|
||||
# SSE mode exposes port 3001, stdio mode doesn't need ports
|
||||
- "3001:3001"
|
||||
- "127.0.0.1:3001:3001"
|
||||
environment:
|
||||
# DATABASE_URL is auto-constructed from PostgreSQL credentials (not in .env.docker)
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-memento}:${POSTGRES_PASSWORD:-memento}@postgres:5432/${POSTGRES_DB:-memento}
|
||||
@@ -150,7 +150,7 @@ services:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --header \"x-api-key: ${MCP_API_KEY:-dev-key}\" -q -O /dev/null http://localhost:3001/ || exit 1"]
|
||||
test: ["CMD-SHELL", "wget --header \"x-api-key: $${MCP_API_KEY:-dev-key}\" -q -O /dev/null http://localhost:3001/ || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -163,7 +163,7 @@ services:
|
||||
image: ollama/ollama:latest
|
||||
container_name: memento-ollama
|
||||
ports:
|
||||
- "11434:11434"
|
||||
- "127.0.0.1:11434:11434"
|
||||
volumes:
|
||||
- ollama-data:/root/.ollama
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -305,7 +305,7 @@ Résultat attendu :
|
||||
|
||||
## Étape 7 : Tester le brainstorm
|
||||
|
||||
1. Ouvrir https://note.parsanet.org/brainstorm
|
||||
1. Ouvrir https://memento-note.com/brainstorm
|
||||
2. Taper une idée et cliquer le bouton +
|
||||
3. Vérifier que les 9 idées apparaissent sur le canvas
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
│ │
|
||||
│ Cloudflare │ rsync chiffré
|
||||
▼ ▼
|
||||
note.parsanet.org Serveur hors-site
|
||||
memento-note.com Serveur hors-site
|
||||
```
|
||||
|
||||
---
|
||||
@@ -203,7 +203,7 @@ docker compose -f docker-compose.monitoring.yml ps
|
||||
### 2.6 Health API
|
||||
|
||||
```
|
||||
GET https://note.parsanet.org/api/admin/health
|
||||
GET https://memento-note.com/api/admin/health
|
||||
```
|
||||
|
||||
Retourne :
|
||||
@@ -256,7 +256,7 @@ Push → Gitea Actions CI → Lint + Test + Build
|
||||
- `CUSTOM_OPENAI_API_KEY`
|
||||
|
||||
**Variables :**
|
||||
- `APP_URL` — https://note.parsanet.org
|
||||
- `APP_URL` — https://memento-note.com
|
||||
- `ADMIN_EMAIL`
|
||||
- `POSTGRES_USER`, `POSTGRES_DB`, `POSTGRES_PORT`
|
||||
- Toutes les variables AI provider
|
||||
|
||||
211
docs/referral-system-design.md
Normal file
211
docs/referral-system-design.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# 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 **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 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).
|
||||
3. **Simplicité & Automatisation** : Le parrainage doit se faire par lien unique et être validé automatiquement via notre webhook Stripe actuel.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture de Données (Prisma)
|
||||
|
||||
Pour suivre les relations de parrainage et distribuer les récompenses de manière sécurisée et sans doublons, nous proposons d'étendre le schéma Prisma actuel.
|
||||
|
||||
```prisma
|
||||
// memento-note/prisma/schema.prisma
|
||||
|
||||
model User {
|
||||
// ... champs existants ...
|
||||
|
||||
// Système de Parrainage
|
||||
referralCode String @unique // Code unique généré pour l'utilisateur (ex: "SEPEHR50")
|
||||
referredById String? // ID du parrain qui l'a invité
|
||||
referredBy User? @relation("UserReferrals", fields: [referredById], references: [id])
|
||||
referrals User[] @relation("UserReferrals")
|
||||
|
||||
referralRewards ReferralReward[] // Historique des récompenses distribuées
|
||||
}
|
||||
|
||||
model ReferralReward {
|
||||
id String @id @default(cuid())
|
||||
userId String // Le parrain récompensé
|
||||
refereeId String // Le filleul qui a déclenché la récompense
|
||||
rewardType String // "FREE_MONTH" (Stripe) ou "AI_CREDITS" (Redis)
|
||||
status String @default("PENDING") // "PENDING", "COMPLETED", "FAILED"
|
||||
stripeTxId String? // ID de la transaction de crédit Stripe si applicable
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Le Parcours Utilisateur pas à pas
|
||||
|
||||
### 3.1 Génération et Partage du Lien
|
||||
1. Chaque utilisateur inscrit reçoit automatiquement un code unique lors de la création de son compte (généré à partir de son nom + 4 chiffres uniques, ou un cuid court).
|
||||
2. Un nouvel onglet **"Parrainage"** est ajouté dans `/settings/billing` :
|
||||
- Affiche son code promo de parrainage : `MOMENTO-SEPEHR-1234`
|
||||
- Affiche son lien d'invitation unique : `https://memento-note.com/signup?ref=MOMENTO-SEPEHR-1234`
|
||||
- Affiche un bouton de partage rapide (LinkedIn, X, Email, Clipboard).
|
||||
|
||||
### 3.2 Inscription du Filleul (Cookie & localStorage)
|
||||
1. Le filleul clique sur le lien `https://memento-note.com/signup?ref=MOMENTO-SEPEHR-1234`.
|
||||
2. Le frontend intercepte le paramètre `ref` dans l'URL et le stocke de manière persistante dans un cookie ou dans le `localStorage` (durée : 30 jours).
|
||||
3. Lorsque le filleul valide son formulaire d'inscription :
|
||||
- Le serveur lit le code promo de parrainage dans la requête.
|
||||
- S'il est valide, il associe le `referredById` du nouveau compte `User` à l'ID du parrain.
|
||||
|
||||
### 3.3 Achat de l'Abonnement et Déclenchement de la Récompense
|
||||
1. Lors du checkout sur le plan Pro, le filleul utilise le code promo Stripe `MOMENTO-SEPEHR-1234` (qui est configuré dans votre Stripe Dashboard comme un code promotionnel avec une réduction de 10%).
|
||||
2. Une fois le paiement validé, Stripe émet l'événement `checkout.session.completed` à notre webhook `/api/billing/webhook`.
|
||||
3. Notre helper `sync-subscription-from-stripe.ts` traite l'activation de l'abonnement :
|
||||
- Il vérifie en base de données si le nouvel abonné possède un parrain (`referredById !== null`).
|
||||
- Il vérifie si une récompense pour ce couple Parrain/Filleul a déjà été créée (évite le double-déclenchement).
|
||||
- Il déclenche la distribution des récompenses.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implémentation Technique des Récompenses
|
||||
|
||||
### Option A (Recommandée) : Offrir 1 mois gratuit sur Stripe au Parrain
|
||||
Pour récompenser le parrain de manière entièrement automatisée, nous utilisons le système de **Solde Client (Customer Balance)** de Stripe. On ajoute un crédit négatif (ex. `-9.90 €`) sur le compte client Stripe du parrain. Lors de son prochain renouvellement, Stripe déduira automatiquement ce crédit, ce qui lui offrira son mois gratuit.
|
||||
|
||||
```typescript
|
||||
// lib/billing/referral.ts
|
||||
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function processReferralReward(refereeUserId: string) {
|
||||
// 1. Récupérer le filleul et son parrain
|
||||
const referee = await prisma.user.findUnique({
|
||||
where: { id: refereeUserId },
|
||||
include: { subscription: true }
|
||||
});
|
||||
|
||||
if (!referee || !referee.referredById) return;
|
||||
|
||||
const parrainId = referee.referredById;
|
||||
|
||||
// 2. Vérifier si la récompense est déjà octroyée
|
||||
const existingReward = await prisma.referralReward.findFirst({
|
||||
where: { userId: parrainId, refereeId: refereeUserId }
|
||||
});
|
||||
if (existingReward) return;
|
||||
|
||||
// 3. Récupérer l'abonnement Stripe du parrain
|
||||
const parrainSub = await prisma.subscription.findUnique({
|
||||
where: { userId: parrainId }
|
||||
});
|
||||
|
||||
if (!parrainSub || !parrainSub.stripeCustomerId) {
|
||||
// Si le parrain n'est pas encore client Stripe ou n'a pas d'abonnement actif,
|
||||
// on peut lui créditer des crédits IA en bonus à la place
|
||||
await awardAiCredits(parrainId, 200); // ex: +200 crédits IA
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 4. Créer une transaction de solde Stripe (Créditer son compte de 9,90 €)
|
||||
const amountInCents = 990; // Le prix du plan PRO
|
||||
const balanceTransaction = await stripe.customers.createBalanceTransaction(
|
||||
parrainSub.stripeCustomerId,
|
||||
{
|
||||
amount: -amountInCents, // Un montant NÉGATIF crédite le client Stripe
|
||||
currency: 'eur',
|
||||
description: `Récompense de parrainage pour l'invitation de ${referee.email}`,
|
||||
}
|
||||
);
|
||||
|
||||
// 5. Enregistrer la récompense dans notre base PostgreSQL
|
||||
await prisma.referralReward.create({
|
||||
data: {
|
||||
userId: parrainId,
|
||||
refereeId: refereeUserId,
|
||||
rewardType: 'FREE_MONTH',
|
||||
status: 'COMPLETED',
|
||||
stripeTxId: balanceTransaction.id
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[Referral] Parrain ${parrainId} récompensé avec succès d'un mois gratuit !`);
|
||||
} catch (err) {
|
||||
console.error('[Referral] Erreur lors de l\'octroi du crédit Stripe :', err);
|
||||
|
||||
await prisma.referralReward.create({
|
||||
data: {
|
||||
userId: parrainId,
|
||||
refereeId: refereeUserId,
|
||||
rewardType: 'FREE_MONTH',
|
||||
status: 'FAILED'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option B : Offrir des crédits IA (Redis) au Parrain
|
||||
Si le parrain a un compte gratuit, on peut simplement augmenter son quota de crédits IA ou lui offrir des crédits "boost" à vie.
|
||||
|
||||
```typescript
|
||||
// lib/billing/referral.ts
|
||||
import { redis } from '@/lib/redis';
|
||||
|
||||
async function awardAiCredits(userId: string, amount: number) {
|
||||
// Ajouter des crédits bonus dans Redis pour la période en cours
|
||||
const period = getCurrentPeriodKey();
|
||||
const key = `usage:${userId}:semantic_search:bonus`;
|
||||
await redis.incrby(key, amount);
|
||||
|
||||
await prisma.referralReward.create({
|
||||
data: {
|
||||
userId,
|
||||
refereeId: refereeUserId,
|
||||
rewardType: 'AI_CREDITS',
|
||||
status: 'COMPLETED'
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Intégration Webhook Stripe (Finalisation)
|
||||
|
||||
Dans le webhook `app/api/billing/webhook/route.ts`, lors du traitement de `checkout.session.completed`, il suffit d'appeler notre fonction de parrainage :
|
||||
|
||||
```typescript
|
||||
// app/api/billing/webhook/route.ts
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const userId = session.metadata?.userId;
|
||||
|
||||
if (userId) {
|
||||
// 1. Synchroniser l'abonnement
|
||||
await syncSubscriptionFromStripe(subscription, userId);
|
||||
|
||||
// 2. Traiter le parrainage si applicable
|
||||
await processReferralReward(userId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Plan de Lancement & Coupon Stripe (No-Code)
|
||||
|
||||
Pour que ce système fonctionne immédiatement :
|
||||
1. Créez un **Coupon** général de `10%` sur Stripe nommé "Parrainage Filleul".
|
||||
2. Dans Stripe, créez un **Code de Promotion orienté client** attaché à ce coupon.
|
||||
3. Configurez ce code promo avec le motif suivant : **Autoriser les codes de promotion générés par les utilisateurs** ou créez un code parrain par défaut.
|
||||
4. Dans le code de l'application, nous générons automatiquement le code de parrainage de l'utilisateur lors de son premier partage et l'associons à son compte.
|
||||
@@ -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
|
||||
@@ -35,14 +35,14 @@
|
||||
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
||||
|
||||
generated: 2026-05-14T16:06:50Z
|
||||
last_updated: 2026-05-23T20:03:48Z
|
||||
project: Momento
|
||||
last_updated: 2026-06-28T16:45:00Z
|
||||
project: Memento
|
||||
project_key: NOKEY
|
||||
tracking_system: file-system
|
||||
story_location: docs
|
||||
|
||||
development_status:
|
||||
epic-3: in-progress
|
||||
epic-3: done
|
||||
3-1-freemium-quota-tracking: done
|
||||
3-2-custom-llm-router: done
|
||||
3-3-smart-routing-fallback: done
|
||||
@@ -50,7 +50,7 @@ development_status:
|
||||
3-5-secure-byok-management: done
|
||||
3-6-stripe-subscription-tiers: done
|
||||
epic-3-retrospective: optional
|
||||
epic-4: in-progress
|
||||
epic-4: done
|
||||
4-1-gdpr-cookie-consent: done
|
||||
4-2-gdpr-right-to-be-forgotten: done
|
||||
4-3-data-portability: done
|
||||
@@ -58,11 +58,12 @@ development_status:
|
||||
4-5-eu-data-residency: done
|
||||
4-6-sso-saml-audit-logging: done
|
||||
epic-4-retrospective: optional
|
||||
epic-5: in-progress
|
||||
epic-5: done
|
||||
5-1-nextgen-editor: done
|
||||
epic-5-retrospective: optional
|
||||
|
||||
# Epic 6 — Croissance & Activation (PLG) — ajouté 2026-05-29
|
||||
epic-6: in-progress
|
||||
epic-6: done
|
||||
6-1-onboarding-activation: done # story-onboarding-activation.md
|
||||
6-2-markdown-roundtrip: done # brief-markdown-roundtrip.md
|
||||
6-3-brainstorm-canvas-finalize: done # story: 6-3-brainstorm-canvas-finalize.md
|
||||
|
||||
@@ -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 |
|
||||
|
||||
761
docs/story-chunk-embeddings.md
Normal file
761
docs/story-chunk-embeddings.md
Normal file
@@ -0,0 +1,761 @@
|
||||
# Story: Embeddings par Fragments — Indexation Sémantique Multi-Niveaux
|
||||
|
||||
> **Epic:** Fondation IA — Recherche & Insights Sémantiques
|
||||
> **ID:** US-CHUNK-EMBEDDINGS
|
||||
> **Priority:** Critical — Fondation pour Workstreams B (AI Overview), Memory Echo précis, Insights
|
||||
> **Status:** ready-for-dev
|
||||
> **Depends on:** pgvector existant (✅), `embedding.service.ts` (✅), `NoteEmbedding` (✅)
|
||||
> **Blocks:** US-AI-OVERVIEW, US-CALENDAR (indépendant), qualité Memory Echo et Insights
|
||||
> **Inspiration:** AppFlowy `flowy-ai/src/embeddings/` — chunking + dedup par hash de contenu
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
### Problème actuel
|
||||
|
||||
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`)
|
||||
3. Embed chaque chunk via l'API
|
||||
4. **Mean-pool** les vecteurs en un seul vecteur moyen
|
||||
|
||||
Cette approche a trois limites majeures :
|
||||
|
||||
| Limite | Impact |
|
||||
|--------|--------|
|
||||
| **Perte de précision** — une note de 5000 mots sur 10 sujets différents a un vecteur moyen flou | Memory Echo rate les connexions spécifiques à une section |
|
||||
| **Re-embed complet à chaque modif** — corriger une typo re-embedde toute la note | Coût API × nombre d'éditions ; latence inutile |
|
||||
| **Pas de snippets de match** — la recherche retourne un score global mais pas le passage précis | L'utilisateur ne sait pas *pourquoi* une note match |
|
||||
|
||||
### Solution proposée
|
||||
|
||||
Inspiré d'AppFlowy (`flowy-ai/src/embeddings/document_indexer.rs`, `scheduler.rs`) :
|
||||
|
||||
1. **Découper chaque note en fragments sémantiques** (~1000 chars, coupure aux limites de paragraphes)
|
||||
2. **Embedder chaque fragment indépendamment** dans une nouvelle table `NoteEmbeddingChunk`
|
||||
3. **Hasher le contenu de chaque fragment** (`sha256`) pour ne re-embed que les fragments modifiés
|
||||
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 → Memento
|
||||
|
||||
| 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) |
|
||||
| Vector DB | SQLite-vec (local) | pgvector (PostgreSQL, déjà en prod) |
|
||||
| Scheduler | Tokio channels (generate → write) | `p-queue` ou Bull (Redis déjà présent) |
|
||||
| Dedup | Compare `fragment_id` en DB | Identique — `@@unique([noteId, fragmentId])` |
|
||||
| Embedding model | `nomic-embed-text` (Ollama local) | `text-embedding-3-small` (OpenAI, déjà configuré) |
|
||||
|
||||
---
|
||||
|
||||
## Migration Prisma requise
|
||||
|
||||
```prisma
|
||||
model NoteEmbeddingChunk {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
fragmentId String // sha256(noteId + content) — stable pour le dedup
|
||||
chunkIndex Int // ordre du fragment dans la note
|
||||
content String // texte plain du fragment (~1000 chars)
|
||||
charCount Int // longueur du fragment (audit / debug)
|
||||
embedding Unsupported("vector(1536)")?
|
||||
embeddingModel String? @default("text-embedding-3-small")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([noteId, fragmentId])
|
||||
@@index([noteId])
|
||||
@@index([fragmentId])
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **Migration additive uniquement** — safe, pas de perte de données. La table `NoteEmbedding` existante est conservée intacte.
|
||||
|
||||
### Étapes de migration
|
||||
|
||||
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
|
||||
CREATE INDEX "NoteEmbeddingChunk_embedding_hnsw_idx"
|
||||
ON "NoteEmbeddingChunk"
|
||||
USING hnsw ("embedding" vector_cosine_ops)
|
||||
WITH (m = 16, ef_construction = 64);
|
||||
```
|
||||
> L'index HNSW doit être créé manuellement (Prisma ne gère pas les index vectoriels). À mettre dans la migration SQL brute.
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
### US-CHUNK-1 : Chunking sémantique d'une note
|
||||
|
||||
**En tant que** système d'indexation,
|
||||
**Je veux** découper le contenu d'une note en fragments sémantiques cohérents,
|
||||
**Afin de** produire des embeddings précis au niveau section plutôt qu'au niveau note entière.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** une note avec un titre et un contenu HTML/plain
|
||||
- **Quand** le service de chunking est appelé
|
||||
- **Alors** le contenu est converti en plain text (via `stripHtmlToPlainText` existant)
|
||||
- **Et** découpé aux limites de paragraphes (`\n\n`) en groupes de ~1000 caractères max
|
||||
- **Et** chaque fragment chevauche le précédent de ~200 caractères (préserve le contexte aux frontières)
|
||||
- **Et** chaque fragment reçoit un `fragmentId` = `sha256(noteId + "::" + fragmentContent)` (stable, déterministe)
|
||||
- **Et** si un paragraphe dépasse 1000 chars, il est sous-découpé à la phrase la plus proche (regex sur `. ! ? ؟ !。`)
|
||||
- **Et** si le contenu total fait moins de 1000 chars, un seul fragment est produit
|
||||
- **Et** les fragments vides (< 10 chars après trim) sont filtrés
|
||||
|
||||
#### Paramètres de chunking :
|
||||
|
||||
```typescript
|
||||
const CHUNK_TARGET_CHARS = 1000 // taille cible par fragment
|
||||
const CHUNK_OVERLAP_CHARS = 200 // chevauchement entre fragments consécutifs
|
||||
const MIN_FRAGMENT_CHARS = 10 // en dessous, le fragment est ignoré
|
||||
const MAX_PARAGRAPH_SPLIT = 1500 // au-dessus, un paragraphe est sous-découpé
|
||||
```
|
||||
|
||||
> Ces valeurs sont alignées sur AppFlowy (`document_indexer.rs:31`) et la recherche RAG standard (medium chunks 256-1024 tokens).
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-2 : Indexation incrémentale avec dedup
|
||||
|
||||
**En tant que** système,
|
||||
**Je veux** ne re-embed que les fragments qui ont réellement changé,
|
||||
**Afin de** réduire le coût API et la latence lors des éditions mineures.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** une note qui a déjà des fragments indexés en DB
|
||||
- **Quand** l'utilisateur modifie un paragraphe et sauvegarde
|
||||
- **Alors** le service :
|
||||
1. Récupère les `fragmentId`s existants : `SELECT fragmentId FROM "NoteEmbeddingChunk" WHERE noteId = $1`
|
||||
2. Chunk le contenu actuel → produit de nouveaux fragments avec leurs `fragmentId`s
|
||||
3. Compare les hash :
|
||||
- **Fragments inchangés** (hash identique) → **skip**, pas d'embed
|
||||
- **Fragments nouveaux** (hash absent en DB) → **embed et insère**
|
||||
- **Fragments supprimés** (hash en DB mais absent des nouveaux) → **DELETE de la DB**
|
||||
4. Embed uniquement les fragments nouveaux (batch de max 100 fragments par appel API)
|
||||
5. Insère en DB via upsert transactionnel
|
||||
- **Et** si l'utilisateur corrige une typo dans un paragraphe, seul ce fragment est re-embeddé (pas toute la note)
|
||||
- **Et** si l'utilisateur ajoute un nouveau paragraphe à la fin, seul le nouveau fragment est embeddé
|
||||
- **Et** les fragments supprimés sont nettoyés (pas d'orphelins)
|
||||
|
||||
#### Gestion de la concurrence :
|
||||
|
||||
- Utiliser un **verrou par noteId** (mutex en mémoire ou lock Redis `lock:chunk-index:{noteId}`)
|
||||
- Si une indexation est déjà en cours pour cette note, la nouvelle requête est mise en file d'attente (ou débounce 3s puis prend le relais)
|
||||
- Pas de race condition sur les upserts (`@@unique([noteId, fragmentId])` + `ON CONFLICT DO UPDATE`)
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-3 : Pipeline d'indexation asynchrone
|
||||
|
||||
**En tant que** système,
|
||||
**Je veux** que l'indexation des fragments se fasse en arrière-plan sans bloquer la sauvegarde,
|
||||
**Afin de** préserver la fluidité de l'édition.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Quand** une note est sauvegardée (débounce existant dans le flow de save)
|
||||
- **Alors** l'indexation est déclenchée **de manière asynchrone** (fire-and-forget côté serveur)
|
||||
- **Et** l'utilisateur ne perçoit aucune latence liée à l'embedding
|
||||
- **Et** l'indexation utilise une **queue avec concurrence limitée** (max 4 embeddings simultanés) pour éviter le rate-limit API
|
||||
- **Et** en cas d'erreur API (rate limit, timeout), le fragment est **réessayé** avec backoff exponentiel (3 tentatives max)
|
||||
- **Et** en cas d'échec définitif, l'erreur est loggée mais ne bloque pas l'application
|
||||
|
||||
#### Implémentation de la queue :
|
||||
|
||||
Utiliser `p-queue` (léger, pas de dépendance Redis supplémentaire) ou Bull (Redis déjà présent à `memento-redis:6379`). Préférer **`p-queue`** pour simplicité — un singleton process-level :
|
||||
|
||||
```typescript
|
||||
import PQueue from 'p-queue'
|
||||
const chunkEmbeddingQueue = new PQueue({ concurrency: 4 })
|
||||
```
|
||||
|
||||
> Si Memento passe multi-process (PM2 cluster), migrer vers Bull. Pour l'instant, single-process Next.js → `p-queue` suffit.
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-4 : Recherche sémantique au niveau fragment
|
||||
|
||||
**En tant qu'** utilisateur,
|
||||
**Je veux** que la recherche sémantique trouve des passages précis dans mes notes,
|
||||
**Afin de** comprendre pourquoi une note correspond à ma recherche.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** que l'utilisateur saisit une requête dans la SearchModal
|
||||
- **Quand** la recherche sémantique s'exécute
|
||||
- **Alors** le système effectue **deux recherches en parallèle** :
|
||||
1. **Recherche fragment-level** (nouvelle) : `pgvector` sur `NoteEmbeddingChunk.embedding`
|
||||
2. **Recherche note-level** (existante) : `pgvector` sur `NoteEmbedding.embedding` + FTS `tsvector`
|
||||
- **Et** les résultats sont fusionnés via RRF (Reciprocal Rank Fusion, déjà implémenté dans `semantic-search.service.ts`)
|
||||
- **Et** chaque résultat inclut désormais un champ `matchedSnippets: string[]` (top 3 fragments par score, max 200 chars chacun, avec `...` aux coupures)
|
||||
- **Et** les snippets sont affichés dans l'UI de recherche sous le titre de la note (mise en gras du terme si présent)
|
||||
|
||||
#### Requête SQL fragment-level :
|
||||
|
||||
```sql
|
||||
-- Recherche vectorielle au niveau fragment, agrégée par note
|
||||
WITH chunk_scores AS (
|
||||
SELECT
|
||||
c."noteId",
|
||||
MAX(1 - (c."embedding"::vector <=> $1::vector)) AS best_score,
|
||||
ARRAY_AGG(c.content ORDER BY 1 - (c."embedding"::vector <=> $1::vector) DESC) AS ranked_contents
|
||||
FROM "NoteEmbeddingChunk" c
|
||||
JOIN "Note" n ON n.id = c."noteId"
|
||||
WHERE n."userId" = $2
|
||||
AND c."embedding" IS NOT NULL
|
||||
AND 1 - (c."embedding"::vector <=> $1::vector) >= $3 -- threshold
|
||||
GROUP BY c."noteId"
|
||||
LIMIT $4
|
||||
)
|
||||
SELECT
|
||||
cs."noteId",
|
||||
cs.best_score,
|
||||
cs.ranked_contents[1:3] AS top_snippets -- top 3 fragments
|
||||
FROM chunk_scores cs
|
||||
ORDER BY cs.best_score DESC
|
||||
```
|
||||
|
||||
> Le `[1:3]` est du slicing PostgreSQL natif sur les arrays. Chaque snippet est tronqué à 200 chars côté application.
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-5 : Memory Echo précis au niveau fragment
|
||||
|
||||
**En tant qu'** utilisateur,
|
||||
**Je veux** que Memory Echo détecte des connexions entre sections spécifiques de notes,
|
||||
**Afin de** découvrir des résonances même entre notes majoritairement différentes.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** deux notes A et B indexées en fragments
|
||||
- **Quand** Memory Echo calcule la similarité entre A et B
|
||||
- **Alors** au lieu de comparer `embedding(A)` vs `embedding(B)` (note entière)
|
||||
- **Le système compare** la **meilleure paire de fragments** : `MAX(similarity(fragmentAi, fragmentBj))` sur tous les cross-joins
|
||||
- **Et** si la meilleure paire dépasse le seuil (`SEMANTIC_SIMILARITY_FLOOR`), une connexion est créée
|
||||
- **Et** l'insight Memory Echo affiche le **snippet précis** qui a résonné (pas juste le score global)
|
||||
- **Et** le filtre temporel (`MIN_DAYS_APART`) reste identique
|
||||
|
||||
#### Requête SQL (cross-join fragment) :
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
a."noteId" AS note1_id,
|
||||
b."noteId" AS note2_id,
|
||||
MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) AS best_fragment_similarity,
|
||||
-- Récupérer le contenu des fragments qui matchent le mieux
|
||||
(
|
||||
SELECT a2.content
|
||||
FROM "NoteEmbeddingChunk" a2
|
||||
WHERE a2."noteId" = a."noteId"
|
||||
AND 1 - (a2."embedding"::vector <=> b."embedding"::vector) = MAX(1 - (a."embedding"::vector <=> b."embedding"::vector))
|
||||
LIMIT 1
|
||||
) AS note1_snippet,
|
||||
(
|
||||
SELECT b2.content
|
||||
FROM "NoteEmbeddingChunk" b2
|
||||
WHERE b2."noteId" = b."noteId"
|
||||
AND 1 - (a2."embedding"::vector <=> b2."embedding"::vector) = MAX(...)
|
||||
LIMIT 1
|
||||
) AS note2_snippet
|
||||
FROM "NoteEmbeddingChunk" a
|
||||
JOIN "NoteEmbeddingChunk" b ON a."noteId" < b."noteId" -- évite les doublons et self-joins
|
||||
JOIN "Note" na ON na.id = a."noteId"
|
||||
JOIN "Note" nb ON nb.id = b."noteId"
|
||||
WHERE na."userId" = $1
|
||||
AND nb."userId" = $1
|
||||
AND a."embedding" IS NOT NULL
|
||||
AND b."embedding" IS NOT NULL
|
||||
GROUP BY a."noteId", b."noteId"
|
||||
HAVING MAX(1 - (a."embedding"::vector <=> b."embedding"::vector)) >= $2
|
||||
```
|
||||
|
||||
> ⚠️ **Performance** : ce cross-join est coûteux. L'exécuter uniquement pour la note qui vient d'être indexée (pas sur toutes les paires à chaque fois). Pour la note N nouvellement indexée : `JOIN` ses fragments contre TOUS les autres fragments, `MAX()` par noteId cible, filtrer par seuil.
|
||||
|
||||
#### Flux Memory Echo mis à jour :
|
||||
|
||||
```
|
||||
Note N sauvegardée → chunks indexés → pour chaque autre note M :
|
||||
best_similarity = MAX over (fragment_N_i, fragment_M_j)
|
||||
if best_similarity >= THRESHOLD and passesTimeDiversityFilter(N, M):
|
||||
create MemoryEchoInsight with note1_snippet + note2_snippet
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-6 : Migration rétroactive des notes existantes
|
||||
|
||||
**En tant qu'** admin/système,
|
||||
**Je veux** indexer en fragments toutes les notes qui ont déjà un embedding global,
|
||||
**Afin de** bénéficier de la précision fragment-level sur le corpus existant.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** que des notes ont un `NoteEmbedding` mais pas de `NoteEmbeddingChunk`
|
||||
- **Quand** l'admin lance le script de migration
|
||||
- **Alors** pour chaque note (batch de 50) :
|
||||
1. Récupérer le plain text (titre + corps)
|
||||
2. Chunker en fragments
|
||||
3. Embedder chaque fragment (respect du rate-limit API)
|
||||
4. Insérer en DB
|
||||
- **Et** une barre de progression affiche : `1234 / 5678 notes (21%) — ETA: ~8 min`
|
||||
- **Et** le script est **interruptible** (Ctrl+C safe — reprend là où il s'est arrêté via un checkpoint)
|
||||
- **Et** le script est **idempotent** (les notes déjà chunkées sont skip)
|
||||
- **Et** le script logge les erreurs par note mais continue
|
||||
- **Et** le coût API estimé est affiché avant confirmation
|
||||
|
||||
#### Script :
|
||||
|
||||
```bash
|
||||
# Lancement manuel (pas en CI)
|
||||
npx tsx scripts/migrate-chunk-embeddings.ts --batch-size=50 --concurrency=4
|
||||
```
|
||||
|
||||
#### Estimation de coût :
|
||||
|
||||
- ~1 note moyenne = ~3000 chars = ~4 fragments
|
||||
- `text-embedding-3-small` : $0.02 / 1M tokens ≈ $0.02 / 750K chars
|
||||
- 1000 notes × 4 fragments × 1000 chars = 4M chars ≈ $0.10
|
||||
- **Négligeable** pour la plupart des corpus
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-7 : Affichage des snippets dans la SearchModal
|
||||
|
||||
**En tant qu'** utilisateur,
|
||||
**Je veux** voir les passages précis qui correspondent à ma recherche,
|
||||
**Afin de** évaluer rapidement la pertinence d'un résultat.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Étant donné** des résultats de recherche avec `matchedSnippets`
|
||||
- **Quand** ils sont affichés dans la SearchModal
|
||||
- **Alors** sous chaque titre de note, les top 1-2 snippets sont affichés (max 150 chars visibles, `...` aux coupures)
|
||||
- **Et** le terme de recherche est **surligné en gras** dans les snippets
|
||||
- **Et** les snippets sont en couleur secondaire (text-muted, plus petit que le titre)
|
||||
- **Et** si aucun snippet fragment-level n'est disponible (note pas encore migrée), le comportement actuel est conservé (extrait du début de la note)
|
||||
|
||||
#### Design :
|
||||
|
||||
- Consulter le prototype `architectural-grid/` → `SearchModal` pour le design exact des snippets dans les résultats
|
||||
- Style Google-like : snippet gris sous le titre, terme de recherche en gras
|
||||
|
||||
---
|
||||
|
||||
### US-CHUNK-8 : Nettoyage à la suppression de note
|
||||
|
||||
**En tant que** système,
|
||||
**Je veux** que les fragments d'une note supprimée soient automatiquement supprimés,
|
||||
**Afin de** ne pas laisser de vecteurs orphelins.
|
||||
|
||||
#### Critères d'acceptation :
|
||||
|
||||
- **Quand** une note est supprimée
|
||||
- **Alors** tous ses `NoteEmbeddingChunk` sont supprimés en cascade (`onDelete: Cascade` dans le schéma Prisma)
|
||||
- **Et** aucun vecteur orphelin ne subsiste
|
||||
- **Et** les Memory Echo référençant cette note sont nettoyés (comportement existant)
|
||||
|
||||
> Le `onDelete: Cascade` sur la relation `note` dans le schéma gère cela automatiquement au niveau DB.
|
||||
|
||||
---
|
||||
|
||||
## Fichiers à créer / modifier
|
||||
|
||||
| Fichier | Action | Notes |
|
||||
|---------|--------|-------|
|
||||
| `prisma/schema.prisma` | Modifier | Ajouter modèle `NoteEmbeddingChunk` + relation inverse sur `Note` |
|
||||
| `prisma/migrations/xxx_add_note_embedding_chunks/` | Créer | Migration + SQL brut pour index HNSW |
|
||||
| `lib/text/note-chunking.ts` | **Créer** | Logique de chunking sémantique + hash |
|
||||
| `lib/ai/services/chunk-indexing.service.ts` | **Créer** | Service d'indexation incrémentale (dedup, queue, retry) |
|
||||
| `lib/ai/services/semantic-search.service.ts` | Modifier | Ajouter `vectorChunkSearch()` + fusion RRF avec existant + snippets |
|
||||
| `lib/ai/services/memory-echo.service.ts` | Modifier | Similarité au niveau fragment + snippets dans insights |
|
||||
| `lib/ai/services/embedding.service.ts` | Modifier | Ajouter `generateChunkEmbeddings(texts: string[])` (batch optimisé) |
|
||||
| `app/api/notes/[id]/route.ts` (ou hook de save) | Modifier | Déclencher `chunkIndexingService.indexNote()` après save |
|
||||
| `components/search/search-result-item.tsx` (ou équivalent) | Modifier | Afficher `matchedSnippets` |
|
||||
| `scripts/migrate-chunk-embeddings.ts` | **Créer** | Script de migration rétroactive avec checkpoint |
|
||||
| `locales/en.json` + `locales/fr.json` | Modifier | Clés `search.snippets.*` (minimal) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture technique
|
||||
|
||||
### Pipeline d'indexation (flow complet)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Éditeur (TipTap) │
|
||||
│ User tape → debounce 2s → POST /api/notes/[id] (save) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Note Save Handler (server-side) │
|
||||
│ 1. Sauvegarde contenu en DB (existant) │
|
||||
│ 2. Re-embed NoteEmbedding global (existant, conservé) │
|
||||
│ 3. fire-and-forget → chunkIndexingService.indexNote(noteId) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ (async, non-bloquant)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ChunkIndexingService.indexNote(noteId) │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. Récupérer note (title + content) depuis DB │ │
|
||||
│ │ 2. plain = prepareNoteTextForEmbedding(title, content) │ │
|
||||
│ │ 3. chunks = chunkNoteContent(noteId, plain) │ │
|
||||
│ │ → [{fragmentId, content, chunkIndex}, ...] │ │
|
||||
│ │ 4. existingIds = SELECT fragmentId WHERE noteId │ │
|
||||
│ │ 5. newChunks = chunks WHERE fragmentId NOT IN existing │ │
|
||||
│ │ 6. staleIds = existingIds NOT IN chunks.fragmentIds │ │
|
||||
│ │ 7. DELETE WHERE noteId AND fragmentId IN staleIds │ │
|
||||
│ │ 8. Queue: embed newChunks → INSERT │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ p-queue (concurrency: 4) │
|
||||
│ Pour chaque nouveau fragment : │
|
||||
│ embedding = await embeddingService.embedPlainText(content) │
|
||||
│ INSERT INTO NoteEmbeddingChunk (fragmentId, content, │
|
||||
│ embedding, ...) ON CONFLICT DO UPDATE │
|
||||
│ Retry: 3 tentatives, backoff exponentiel (1s, 2s, 4s) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Chunking — algorithme détaillé
|
||||
|
||||
```typescript
|
||||
// lib/text/note-chunking.ts
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
const CHUNK_TARGET_CHARS = 1000
|
||||
const CHUNK_OVERLAP_CHARS = 200
|
||||
const MIN_FRAGMENT_CHARS = 10
|
||||
const MAX_PARAGRAPH_BEFORE_SPLIT = 1500
|
||||
|
||||
export interface NoteChunk {
|
||||
fragmentId: string
|
||||
content: string
|
||||
chunkIndex: number
|
||||
charCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe une note en fragments sémantiques.
|
||||
* Port TS de flowy-ai/src/embeddings/document_indexer.rs
|
||||
*/
|
||||
export function chunkNoteContent(
|
||||
noteId: string,
|
||||
plainText: string,
|
||||
): NoteChunk[] {
|
||||
const normalized = plainText.trim()
|
||||
if (normalized.length < MIN_FRAGMENT_CHARS) return []
|
||||
|
||||
// 1. Split par paragraphes (double newline)
|
||||
const paragraphs = normalized
|
||||
.split(/\n\s*\n/)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length >= MIN_FRAGMENT_CHARS)
|
||||
|
||||
if (paragraphs.length === 0) return []
|
||||
|
||||
// 2. Sous-découper les paragraphes trop longs
|
||||
const atomicParagraphs: string[] = []
|
||||
for (const para of paragraphs) {
|
||||
if (para.length > MAX_PARAGRAPH_BEFORE_SPLIT) {
|
||||
atomicParagraphs.push(...splitLongParagraph(para, CHUNK_TARGET_CHARS))
|
||||
} else {
|
||||
atomicParagraphs.push(para)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Grouper les paragraphes en chunks de ~CHUNK_TARGET_CHARS
|
||||
const groups = groupParagraphsByMaxContentLen(
|
||||
atomicParagraphs,
|
||||
CHUNK_TARGET_CHARS,
|
||||
CHUNK_OVERLAP_CHARS,
|
||||
)
|
||||
|
||||
// 4. Hash + construire les NoteChunk
|
||||
const chunks: NoteChunk[] = []
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const content = groups[i]
|
||||
if (content.length < MIN_FRAGMENT_CHARS) continue
|
||||
|
||||
const fragmentId = hashFragment(noteId, content)
|
||||
// Dédup : si le même fragmentId existe déjà (paragraphe répété), skip
|
||||
if (chunks.some((c) => c.fragmentId === fragmentId)) continue
|
||||
|
||||
chunks.push({
|
||||
fragmentId,
|
||||
content,
|
||||
chunkIndex: i,
|
||||
charCount: content.length,
|
||||
})
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
function hashFragment(noteId: string, content: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${noteId}::${content}`)
|
||||
.digest('hex')
|
||||
.slice(0, 32) // 32 chars = 128 bits, largement suffisant pour le dedup
|
||||
}
|
||||
|
||||
function splitLongParagraph(para: string, maxLen: number): string[] {
|
||||
// Coupe aux fins de phrase (. ! ? ؟ !。) les plus proches de maxLen
|
||||
const sentences = para.split(/(?<=[.!?؟!。])\s+/)
|
||||
const chunks: string[] = []
|
||||
let current = ''
|
||||
|
||||
for (const sentence of sentences) {
|
||||
if ((current + ' ' + sentence).length > maxLen && current) {
|
||||
chunks.push(current.trim())
|
||||
current = sentence
|
||||
} else {
|
||||
current = current ? `${current} ${sentence}` : sentence
|
||||
}
|
||||
}
|
||||
if (current.trim()) chunks.push(current.trim())
|
||||
|
||||
// Si une phrase seule dépasse maxLen, coupe dur au mot le plus proche
|
||||
return chunks.flatMap((chunk) =>
|
||||
chunk.length > maxLen * 1.5 ? hardSplitByWords(chunk, maxLen) : [chunk],
|
||||
)
|
||||
}
|
||||
|
||||
function hardSplitByWords(text: string, maxLen: string): string[] {
|
||||
// Coupe au mot le plus proche de maxLen
|
||||
// ...
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupe les paragraphes en chunks de taille ~maxContentLen
|
||||
* avec un overlap optionnel pour préserver le contexte.
|
||||
* Port de group_paragraphs_by_max_content_len (document_indexer.rs:147)
|
||||
*/
|
||||
function groupParagraphsByMaxContentLen(
|
||||
paragraphs: string[],
|
||||
maxLen: number,
|
||||
overlap: number,
|
||||
): string[] {
|
||||
if (paragraphs.length === 0) return []
|
||||
if (overlap > maxLen) overlap = maxLen / 2
|
||||
|
||||
const result: string[] = []
|
||||
let current = ''
|
||||
|
||||
for (const para of paragraphs) {
|
||||
if (current.length + para.length > maxLen && current) {
|
||||
result.push(current.trim())
|
||||
// Préserve l'overlap : reprend les derniers `overlap` chars
|
||||
const tail = current.slice(-overlap)
|
||||
current = tail + para
|
||||
} else {
|
||||
current = current ? `${current}\n\n${para}` : para
|
||||
}
|
||||
}
|
||||
if (current.trim()) result.push(current.trim())
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
### Recherche fragment-level — intégration dans SemanticSearchService
|
||||
|
||||
```typescript
|
||||
// Ajout à lib/ai/services/semantic-search.service.ts
|
||||
|
||||
export interface SearchResult {
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
score: number
|
||||
matchType: 'exact' | 'related'
|
||||
language?: string | null
|
||||
matchedSnippets?: string[] // NOUVEAU — top fragments qui matchent
|
||||
}
|
||||
|
||||
class SemanticSearchService {
|
||||
// ... existant ...
|
||||
|
||||
/**
|
||||
* NOUVEAU — Recherche vectorielle au niveau fragment.
|
||||
* Retourne les notes triées par meilleure similarité de fragment.
|
||||
*/
|
||||
private async vectorChunkSearch(
|
||||
queryEmbedding: number[],
|
||||
userId: string,
|
||||
threshold: number,
|
||||
notebookId: string | null,
|
||||
limit: number,
|
||||
): Promise<SearchResult[]> {
|
||||
const vecStr = `[${queryEmbedding.join(',')}]`
|
||||
const params: any[] = [vecStr, userId, threshold, limit]
|
||||
|
||||
let notebookJoin = ''
|
||||
if (notebookId) {
|
||||
notebookJoin = `AND n."notebookId" = $${params.length + 1}`
|
||||
params.push(notebookId)
|
||||
}
|
||||
|
||||
const sql = `
|
||||
WITH chunk_scores AS (
|
||||
SELECT
|
||||
c."noteId",
|
||||
MAX(1 - (c."embedding"::vector <=> $1::vector)) AS best_score,
|
||||
ARRAY_AGG(
|
||||
c.content
|
||||
ORDER BY 1 - (c."embedding"::vector <=> $1::vector) DESC
|
||||
) AS ranked_contents
|
||||
FROM "NoteEmbeddingChunk" c
|
||||
JOIN "Note" n ON n.id = c."noteId"
|
||||
WHERE n."userId" = $2
|
||||
AND c."embedding" IS NOT NULL
|
||||
AND 1 - (c."embedding"::vector <=> $1::vector) >= $3
|
||||
${notebookJoin}
|
||||
GROUP BY c."noteId"
|
||||
LIMIT $4
|
||||
)
|
||||
SELECT
|
||||
cs."noteId",
|
||||
n.title,
|
||||
cs.best_score,
|
||||
cs.ranked_contents[1:3] AS top_snippets
|
||||
FROM chunk_scores cs
|
||||
JOIN "Note" n ON n.id = cs."noteId"
|
||||
ORDER BY cs.best_score DESC
|
||||
`
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe(sql, ...params)
|
||||
return rows.map((row: any) => ({
|
||||
noteId: row.noteId,
|
||||
title: row.title,
|
||||
content: row.top_snippets?.[0] ?? '',
|
||||
score: row.best_score,
|
||||
matchType: 'related' as const,
|
||||
matchedSnippets: (row.top_snippets ?? []).map((s: string) =>
|
||||
s.length > 200 ? s.slice(0, 200) + '...' : s,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche hybride mise à jour :
|
||||
* FTS + vectorChunkSearch + vectorNoteSearch → RRF fusion
|
||||
*/
|
||||
async search(query: string, userId: string, opts: SearchOptions): Promise<SearchResult[]> {
|
||||
// ... existant : FTS ...
|
||||
|
||||
// NOUVEAU : recherche fragment-level en parallèle de la recherche note-level
|
||||
const [keywordResults, noteResults, chunkResults] = await Promise.all([
|
||||
this.keywordSearch(query, userId, opts),
|
||||
this.vectorSearch(queryEmbedding, userId, opts),
|
||||
this.vectorChunkSearch(queryEmbedding, userId, opts.threshold, opts.notebookId, opts.limit),
|
||||
])
|
||||
|
||||
// RRF sur les 3 sources
|
||||
const fused = this.reciprocalRankFusion([
|
||||
...keywordResults,
|
||||
...noteResults,
|
||||
...chunkResults,
|
||||
])
|
||||
|
||||
// Merge snippets : si un résultat a des snippets des chunks, les propager
|
||||
return this.mergeSnippets(fused, chunkResults)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risques et mitigations
|
||||
|
||||
| Risque | Probabilité | Impact | Mitigation |
|
||||
|--------|-------------|--------|------------|
|
||||
| Cross-join fragment coûteux pour Memory Echo | Moyenne | Latence | N'exécuter que pour la note nouvellement indexée (pas sur tout le corpus à chaque fois) |
|
||||
| Coût API embedding x4-5 (plus de fragments) | Faible | $$ | Dedup par hash → seule une fraction est re-embeddée après la migration initiale |
|
||||
| Rate-limit API OpenAI | Moyenne | Blocage | Queue avec concurrence 4 + retry backoff exponentiel |
|
||||
| Migration longue sur gros corpus | Faible | UX admin | Script batch avec checkpoint, interruptible, reprise auto |
|
||||
| Index HNSW non créé (oubli migration) | Faible | Recherche lente | Vérifier l'existence de l'index au démarrage (`getDbDimension` déjà existe pour ça) |
|
||||
| Fragments orphelins si note déplacée entre carnets | Faible | Bruit | `onDelete: Cascade` + nettoyage au move (déjà géré pour `NoteEmbedding`) |
|
||||
|
||||
---
|
||||
|
||||
## Métriques à tracker
|
||||
|
||||
| Métrique | Objectif | Mesure |
|
||||
|----------|----------|--------|
|
||||
| Précision Memory Echo | +30% connexions pertinentes | Taux de feedback positif sur les insights |
|
||||
| Latence recherche sémantique | < 200ms (P95) | Telemetry sur `semantic-search.service.ts` |
|
||||
| Coût API embedding / mois | Stable vs actuel | Compter les appels `embedPlainText` |
|
||||
| Snippets affichés dans recherche | > 80% des résultats | Telemetry sur SearchModal |
|
||||
| Fragments par note (moyenne) | 3-5 | Requête analytique sur `NoteEmbeddingChunk` |
|
||||
|
||||
---
|
||||
|
||||
## Clés i18n (minimal — la majorité est invisible côté UI)
|
||||
|
||||
```json
|
||||
{
|
||||
"search": {
|
||||
"snippets": {
|
||||
"matchedSection": "Section correspondante"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> La fonctionnalité est principalement backend — peu de libellés visibles. Les snippets sont du contenu de note (pas du texte à traduire).
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'implémentation recommandé
|
||||
|
||||
1. **Migration Prisma** — modèle `NoteEmbeddingChunk` + index HNSW
|
||||
2. **`lib/text/note-chunking.ts`** — logique de chunking pure (testable isolément)
|
||||
3. **`lib/ai/services/chunk-indexing.service.ts`** — service d'indexation (dedup, queue)
|
||||
4. **Intégration au hook de save** — déclenchement async
|
||||
5. **Script de migration** — indexer le corpus existant
|
||||
6. **Recherche fragment-level** — `vectorChunkSearch` + fusion RRF + snippets
|
||||
7. **UI snippets dans SearchModal** — affichage des passages précis
|
||||
8. **Memory Echo fragment-level** — similarité par meilleure paire de fragments
|
||||
9. **Validation end-to-end** — créer une note longue, vérifier chunks en DB, rechercher, Memory Echo
|
||||
|
||||
---
|
||||
|
||||
## Définition de Done (DoD)
|
||||
|
||||
- [ ] Migration Prisma appliquée en dev, dump DB validé avant
|
||||
- [ ] `chunkNoteContent()` produit des fragments cohérents (vérifié sur 5 notes de test : courte, longue, multilingue FR/FA, HTML clippé, vide)
|
||||
- [ ] Indexation incrémentale : modifier une typo ne re-embed qu'un fragment (vérifié en DB)
|
||||
- [ ] Recherche sémantique retourne des `matchedSnippets` pertinents
|
||||
- [ ] SearchModal affiche les snippets avec le terme surligné
|
||||
- [ ] Memory Echo détecte des connexions au niveau section
|
||||
- [ ] Script de migration complète le corpus sans erreur
|
||||
- [ ] Aucune régression sur la recherche existante (note-level toujours fonctionnelle)
|
||||
- [ ] `NoteEmbedding` (global) toujours maintenu pour rétro-compat
|
||||
- [ ] Pas de `revalidatePath` systématique (mutations optimistes)
|
||||
- [ ] Pas de tests écrits sauf demande explicite (AGENTS.md)
|
||||
|
||||
---
|
||||
|
||||
## 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)**. 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 |
|
||||
@@ -449,3 +449,44 @@ Devrait retourner :
|
||||
| Enterprise | Sur devis | Sur devis | — |
|
||||
|
||||
Devise : EUR (configurable dans Stripe Dashboard pour multi-devises).
|
||||
|
||||
---
|
||||
|
||||
## 11. Bons de réduction & Codes de promotion (Coupons & Promo Codes)
|
||||
|
||||
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 :
|
||||
1. **Le Bon de réduction (Coupon)** : C'est la règle financière sous-jacente (ex. `-20 % sur l'abonnement pendant 6 mois` ou `-10 € à vie`). Un coupon n'est pas vu par le client final sous cette forme.
|
||||
2. **Le Code de promotion (Promo Code)** : C'est la chaîne de caractères réelle saisie par le client (ex. `WELCOME20`, `LAUNCH50`). Un code promo est obligatoirement rattaché à un Coupon. Vous pouvez créer plusieurs codes promos pour un seul et même coupon (ex. `INFLUENCEUR1` et `INFLUENCEUR2` qui appliquent tous les deux la même réduction de -10%).
|
||||
|
||||
### 11.2 Comment créer un Code Promo sur Stripe (Dashboard)
|
||||
1. Connectez-vous à votre **Stripe Dashboard** (en mode test ou live selon votre cible).
|
||||
2. Rendez-vous dans **Produits** (Products) → **Bons de réduction** (Coupons) → bouton **+ Nouveau**.
|
||||
3. Remplissez les informations du Coupon :
|
||||
- **Nom** : Le nom interne pour vous y retrouver (ex: `Lancement 50%`).
|
||||
- **Type de réduction** : Pourcentage (ex: `50%`) ou Montant fixe (ex: `10 €`).
|
||||
- **Durée** :
|
||||
- *Une seule fois* (appliqué uniquement sur la première facture).
|
||||
- *Plusieurs mois* (Spécifier le nombre de mois, ex: 3 mois).
|
||||
- *Pour toujours* (appliqué à vie sur toutes les factures de l'abonnement).
|
||||
4. Sous le bloc **Codes de promotion**, cochez **"Créer un code de promotion orienté client"** :
|
||||
- **Code** : Saisissez le code en majuscules (ex: `LAUNCH50`).
|
||||
- **Limites d'utilisation** (optionnel) :
|
||||
- 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 `Memento Pro`).
|
||||
5. Cliquez sur **Créer le bon de réduction**.
|
||||
|
||||
### 11.3 Comment désactiver ou supprimer un Code Promo
|
||||
1. Allez dans **Produits** → **Bons de réduction** → **Codes de promotion**.
|
||||
2. Cliquez sur les `...` à côté du code promo concerné.
|
||||
3. Sélectionnez **Désactiver** (Deactivate) : le code ne sera plus utilisable par aucun client, mais les clients en ayant déjà bénéficié conserveront leur réduction active selon la durée définie (ex. s'ils ont déjà eu les 3 mois, Stripe continue de l'appliquer jusqu'à la fin de la période).
|
||||
4. Si vous souhaitez supprimer définitivement un coupon global et annuler la réduction pour tout le monde (y compris les abonnés actuels), allez dans **Bons de Réduction**, cliquez sur le coupon puis sur **Supprimer**.
|
||||
|
||||
### 11.4 Mode Test vs Production
|
||||
* **En local / Test** : Créez vos codes de promotion dans Stripe en ayant activé le commutateur **Mode Test** (Test Mode). Les codes créés ici ne fonctionneront qu'avec vos clés d'API de test (`sk_test_...`).
|
||||
* **En production / Live** : Désactivez le Mode Test sur Stripe pour passer en mode réel, et créez les coupons dans la section Live. Ils ne fonctionneront qu'avec vos clés d'API réelles (`sk_live_...`).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,7 +32,7 @@ import { randomBytes } from 'crypto';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { registerTools } from './tools.js';
|
||||
import { validateApiKey, resolveUser } from './auth.js';
|
||||
import { validateApiKey } from './auth.js';
|
||||
import { requestContext } from './request-context.js';
|
||||
import config, { validateConfig, printConfig } from './config.js';
|
||||
import {
|
||||
@@ -266,6 +266,15 @@ app.get(config.healthPath, async (req, res) => {
|
||||
|
||||
if (config.enableMetrics) {
|
||||
app.get(config.metricsPath, (req, res) => {
|
||||
if (config.requireAuth) {
|
||||
const apiKey = req.headers['x-api-key']
|
||||
const validKey =
|
||||
(apiKey && config.staticApiKey && apiKey === config.staticApiKey) ||
|
||||
(apiKey && process.env.MCP_API_KEY && apiKey === process.env.MCP_API_KEY)
|
||||
if (!validKey) {
|
||||
return res.status(401).json(mcpError(McpErrors.AUTH_FAILED.code, { detail: 'Metrics require x-api-key' }))
|
||||
}
|
||||
}
|
||||
res.set('Content-Type', 'text/plain');
|
||||
res.send(getPrometheusMetrics());
|
||||
});
|
||||
@@ -278,21 +287,25 @@ if (config.enableMetrics) {
|
||||
app.use(
|
||||
withErrorHandling(async (req, res, next) => {
|
||||
if (!config.requireAuth) {
|
||||
req.userSession = { id: 'dev-user', name: 'Development User', isAuth: false };
|
||||
req.userSession = {
|
||||
id: 'dev-user',
|
||||
name: 'Development User',
|
||||
isAuth: false,
|
||||
userId: config.userId || null,
|
||||
};
|
||||
recordAuth(true, 'dev-mode');
|
||||
return next();
|
||||
}
|
||||
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
const headerUserId = req.headers['x-user-id'];
|
||||
|
||||
if (!apiKey && !headerUserId) {
|
||||
if (!apiKey) {
|
||||
recordAuth(false, 'missing-credentials');
|
||||
return res
|
||||
.status(401)
|
||||
.json(
|
||||
mcpError(McpErrors.AUTH_FAILED.code, {
|
||||
detail: 'Provide x-api-key or x-user-id header',
|
||||
detail: 'Provide x-api-key header',
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -328,24 +341,6 @@ app.use(
|
||||
return res.status(401).json(mcpError(McpErrors.AUTH_FAILED.code, { detail: 'Invalid API key' }));
|
||||
}
|
||||
|
||||
if (headerUserId) {
|
||||
const user = await resolveUser(prisma, headerUserId);
|
||||
if (!user) {
|
||||
recordAuth(false, 'user-not-found');
|
||||
return res.status(401).json(mcpError(McpErrors.AUTH_FAILED.code, { detail: 'User not found' }));
|
||||
}
|
||||
req.userSession = getOrCreateSession(`user:${user.id}`, {
|
||||
name: user.name,
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userRole: user.role,
|
||||
authMethod: 'user-id',
|
||||
});
|
||||
recordAuth(true, 'user-id');
|
||||
return next();
|
||||
}
|
||||
|
||||
recordAuth(false, 'auth-failed');
|
||||
return res.status(401).json(mcpError(McpErrors.AUTH_FAILED.code, { detail: 'Authentication failed' }));
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"start:http": "node index-sse.js",
|
||||
"start:sse": "node index-sse.js",
|
||||
"dev": "MCP_LOG_LEVEL=debug node index-sse.js",
|
||||
"test": "node test/test.js",
|
||||
"test": "vitest run",
|
||||
"test:perf": "node test/performance-test.js",
|
||||
"test:connection": "node test/connection-test.js",
|
||||
"test:validation": "node test/validation-test.js",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
McpError,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { requestContext } from './request-context.js';
|
||||
import { mcpErrorContent, McpErrors } from './errors.js';
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT = 50;
|
||||
const DEFAULT_NOTES_LIMIT = 100;
|
||||
@@ -473,26 +474,9 @@ export function registerTools(server, prisma) {
|
||||
return store?.userId || null;
|
||||
};
|
||||
|
||||
let fallbackUserId = null;
|
||||
let fallbackPromise = null;
|
||||
|
||||
const ensureUserId = async () => {
|
||||
const fromContext = getResolvedUserId();
|
||||
if (fromContext) return fromContext;
|
||||
if (fallbackUserId) return fallbackUserId;
|
||||
if (fallbackPromise) return fallbackPromise;
|
||||
|
||||
fallbackPromise = prisma.user.findFirst({ select: { id: true } }).then(u => {
|
||||
if (u) fallbackUserId = u.id;
|
||||
return fallbackUserId;
|
||||
});
|
||||
|
||||
return fallbackPromise;
|
||||
};
|
||||
|
||||
const noteWhere = (resolvedUserId, extra = {}) => ({
|
||||
trashedAt: null,
|
||||
...(resolvedUserId ? { userId: resolvedUserId } : {}),
|
||||
userId: resolvedUserId,
|
||||
...extra,
|
||||
});
|
||||
|
||||
@@ -503,6 +487,11 @@ export function registerTools(server, prisma) {
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const uid = getResolvedUserId();
|
||||
if (!uid) {
|
||||
return mcpErrorContent(McpErrors.AUTH_FAILED.code, {
|
||||
detail: 'Authentication required: missing user context',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
@@ -527,7 +516,7 @@ export function registerTools(server, prisma) {
|
||||
isMarkdown: args.isMarkdown || false,
|
||||
size: args.size || 'small',
|
||||
notebookId: args.notebookId || null,
|
||||
userId: uid || await ensureUserId(),
|
||||
userId: uid,
|
||||
};
|
||||
|
||||
const note = await prisma.note.create({ data });
|
||||
@@ -560,7 +549,7 @@ export function registerTools(server, prisma) {
|
||||
|
||||
case 'get_note': {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
});
|
||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||
return textResult(parseNote(note));
|
||||
@@ -581,7 +570,7 @@ export function registerTools(server, prisma) {
|
||||
d.updatedAt = new Date();
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
data: d,
|
||||
});
|
||||
return textResult(parseNote(note));
|
||||
@@ -589,7 +578,7 @@ export function registerTools(server, prisma) {
|
||||
|
||||
case 'delete_note': {
|
||||
await prisma.note.delete({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
});
|
||||
return textResult({ success: true, deleted: args.id });
|
||||
}
|
||||
@@ -599,8 +588,8 @@ export function registerTools(server, prisma) {
|
||||
const params = [query]
|
||||
let paramIdx = 1
|
||||
|
||||
const userClause = uid ? `AND "userId" = $${++paramIdx}` : ''
|
||||
if (uid) params.push(uid)
|
||||
const userClause = `AND "userId" = $${++paramIdx}`
|
||||
params.push(uid)
|
||||
|
||||
const notebookClause = args.notebookId ? `AND "notebookId" = $${++paramIdx}` : ''
|
||||
if (args.notebookId) params.push(args.notebookId)
|
||||
@@ -628,7 +617,7 @@ export function registerTools(server, prisma) {
|
||||
|
||||
const [note, notebook] = await Promise.all([
|
||||
prisma.note.update({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
data: { notebookId: targetId, updatedAt: new Date() },
|
||||
}),
|
||||
targetId
|
||||
@@ -649,7 +638,7 @@ export function registerTools(server, prisma) {
|
||||
const ids = args.ids || [];
|
||||
|
||||
await prisma.note.updateMany({
|
||||
where: { id: { in: ids }, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
where: { id: { in: ids }, userId: uid, trashedAt: null },
|
||||
data: { notebookId: targetId, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
@@ -659,14 +648,14 @@ export function registerTools(server, prisma) {
|
||||
case 'batch_delete_notes': {
|
||||
const ids = args.ids || [];
|
||||
await prisma.note.deleteMany({
|
||||
where: { id: { in: ids }, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
where: { id: { in: ids }, userId: uid, trashedAt: null },
|
||||
});
|
||||
return textResult({ success: true, count: ids.length });
|
||||
}
|
||||
|
||||
case 'toggle_pin': {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
});
|
||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||
const updated = await prisma.note.update({
|
||||
@@ -678,7 +667,7 @@ export function registerTools(server, prisma) {
|
||||
|
||||
case 'toggle_archive': {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
});
|
||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||
const updated = await prisma.note.update({
|
||||
@@ -689,7 +678,7 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
|
||||
case 'export_notes': {
|
||||
const nbWhere = uid ? { userId: uid } : {};
|
||||
const nbWhere = { userId: uid };
|
||||
|
||||
const [notes, notebooks, labels] = await Promise.all([
|
||||
prisma.note.findMany({
|
||||
@@ -741,7 +730,7 @@ export function registerTools(server, prisma) {
|
||||
|
||||
if (importData.data?.notebooks?.length > 0) {
|
||||
const existing = await prisma.notebook.findMany({
|
||||
where: uid ? { userId: uid } : {},
|
||||
where: { userId: uid },
|
||||
select: { name: true },
|
||||
});
|
||||
const existingNames = new Set(existing.map(nb => nb.name));
|
||||
@@ -752,7 +741,7 @@ export function registerTools(server, prisma) {
|
||||
name: nb.name,
|
||||
icon: nb.icon || '📁',
|
||||
color: nb.color || '#3B82F6',
|
||||
...(uid ? { userId: uid } : {}),
|
||||
userId: uid,
|
||||
}));
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
@@ -763,7 +752,7 @@ export function registerTools(server, prisma) {
|
||||
|
||||
if (importData.data?.labels?.length > 0) {
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: uid ? { userId: uid } : {},
|
||||
where: { userId: uid },
|
||||
select: { id: true },
|
||||
});
|
||||
const nbIds = new Set(notebooks.map(nb => nb.id));
|
||||
@@ -796,7 +785,7 @@ export function registerTools(server, prisma) {
|
||||
size: note.size || 'small',
|
||||
labels: note.labels ?? null,
|
||||
notebookId: note.notebookId || null,
|
||||
...(uid ? { userId: uid } : {}),
|
||||
userId: uid,
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -817,7 +806,7 @@ export function registerTools(server, prisma) {
|
||||
// ═══ NOTEBOOKS ═══
|
||||
case 'create_notebook': {
|
||||
const highest = await prisma.notebook.findFirst({
|
||||
where: uid ? { userId: uid } : {},
|
||||
where: { userId: uid },
|
||||
orderBy: { order: 'desc' },
|
||||
select: { order: true },
|
||||
});
|
||||
@@ -830,7 +819,7 @@ export function registerTools(server, prisma) {
|
||||
color: args.color || '#3B82F6',
|
||||
order: nextOrder,
|
||||
parentId: args.parentId || null,
|
||||
userId: uid || await ensureUserId(),
|
||||
userId: uid,
|
||||
},
|
||||
include: { labels: true, _count: { select: { notes: true } } },
|
||||
});
|
||||
@@ -839,7 +828,7 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
|
||||
case 'get_notebooks': {
|
||||
const where = uid ? { userId: uid } : {};
|
||||
const where = { userId: uid };
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where,
|
||||
include: {
|
||||
@@ -853,7 +842,7 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
|
||||
case 'get_notebook': {
|
||||
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
|
||||
const where = { id: args.id, userId: uid };
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where,
|
||||
include: { labels: true, notes: { where: { trashedAt: null } }, _count: { select: { notes: true } } },
|
||||
@@ -875,7 +864,7 @@ export function registerTools(server, prisma) {
|
||||
if ('order' in args) d.order = args.order;
|
||||
if ('parentId' in args) d.parentId = args.parentId;
|
||||
|
||||
const where = { id: args.id, ...(uid ? { userId: uid } : {}) };
|
||||
const where = { id: args.id, userId: uid };
|
||||
const notebook = await prisma.notebook.update({
|
||||
where,
|
||||
data: d,
|
||||
@@ -888,11 +877,11 @@ export function registerTools(server, prisma) {
|
||||
case 'delete_notebook': {
|
||||
await prisma.$transaction([
|
||||
prisma.note.updateMany({
|
||||
where: { notebookId: args.id, ...(uid ? { userId: uid } : {}) },
|
||||
where: { notebookId: args.id, userId: uid },
|
||||
data: { notebookId: null },
|
||||
}),
|
||||
prisma.notebook.delete({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}) },
|
||||
where: { id: args.id, userId: uid },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -901,7 +890,7 @@ export function registerTools(server, prisma) {
|
||||
|
||||
case 'reorder_notebooks': {
|
||||
const ids = args.notebookIds;
|
||||
const where = { id: { in: ids }, ...(uid ? { userId: uid } : {}) };
|
||||
const where = { id: { in: ids }, userId: uid };
|
||||
|
||||
const existing = await prisma.notebook.findMany({ where, select: { id: true } });
|
||||
const existingIds = new Set(existing.map(nb => nb.id));
|
||||
@@ -920,7 +909,7 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
|
||||
case 'get_notebook_hierarchy': {
|
||||
const where = uid ? { userId: uid } : {};
|
||||
const where = { userId: uid };
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where,
|
||||
include: {
|
||||
@@ -972,7 +961,7 @@ export function registerTools(server, prisma) {
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
const filtered = uid ? labels.filter(l => l.notebook?.userId === uid) : labels;
|
||||
const filtered = labels.filter(l => l.notebook?.userId === uid);
|
||||
return textResult(filtered);
|
||||
}
|
||||
|
||||
@@ -1017,7 +1006,7 @@ export function registerTools(server, prisma) {
|
||||
// ═══ TRASH ═══
|
||||
case 'trash_note': {
|
||||
const note = await prisma.note.update({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}) },
|
||||
where: { id: args.id, userId: uid },
|
||||
data: { trashedAt: new Date() },
|
||||
});
|
||||
return textResult({ success: true, id: note.id, trashedAt: note.trashedAt });
|
||||
@@ -1025,7 +1014,7 @@ export function registerTools(server, prisma) {
|
||||
|
||||
case 'restore_note': {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}) },
|
||||
where: { id: args.id, userId: uid },
|
||||
});
|
||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||
if (!note.trashedAt) return textResult({ success: true, message: 'Note was not in trash' });
|
||||
@@ -1037,7 +1026,7 @@ export function registerTools(server, prisma) {
|
||||
}
|
||||
|
||||
case 'get_trash': {
|
||||
const where = { trashedAt: { not: null }, ...(uid ? { userId: uid } : {}) };
|
||||
const where = { trashedAt: { not: null }, userId: uid };
|
||||
const trashed = await prisma.note.findMany({
|
||||
where,
|
||||
select: { id: true, title: true, content: true, color: true, trashedAt: true, notebookId: true },
|
||||
@@ -1052,7 +1041,7 @@ export function registerTools(server, prisma) {
|
||||
// ═══ ADVANCED NOTE OPERATIONS ═══
|
||||
case 'append_to_note': {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
|
||||
where: { id: args.id, userId: uid, trashedAt: null },
|
||||
select: { content: true },
|
||||
});
|
||||
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
|
||||
@@ -1072,8 +1061,8 @@ export function registerTools(server, prisma) {
|
||||
const params = [query]
|
||||
let paramIdx = 1
|
||||
|
||||
const userClause = uid ? `AND "userId" = $${++paramIdx}` : ''
|
||||
if (uid) params.push(uid)
|
||||
const userClause = `AND "userId" = $${++paramIdx}`
|
||||
params.push(uid)
|
||||
|
||||
const results = await prisma.$queryRawUnsafe(
|
||||
`SELECT id, title, content
|
||||
@@ -1095,7 +1084,7 @@ export function registerTools(server, prisma) {
|
||||
where: {
|
||||
trashedAt: null,
|
||||
isArchived: false,
|
||||
...(uid ? { userId: uid } : {}),
|
||||
userId: uid,
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ content: { contains: query, mode: 'insensitive' } },
|
||||
|
||||
13
memento-mobile/.expo/README.md
Normal file
13
memento-mobile/.expo/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
> Why do I have a folder named ".expo" in my project?
|
||||
|
||||
The ".expo" folder is created when an Expo project is started using "expo start" command.
|
||||
|
||||
> What do the files contain?
|
||||
|
||||
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
|
||||
- "settings.json": contains the server configuration that is used to serve the application manifest.
|
||||
|
||||
> Should I commit the ".expo" folder?
|
||||
|
||||
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
|
||||
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.
|
||||
3
memento-mobile/.expo/devices.json
Normal file
3
memento-mobile/.expo/devices.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"devices": []
|
||||
}
|
||||
14
memento-mobile/.expo/types/router.d.ts
vendored
Normal file
14
memento-mobile/.expo/types/router.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/* eslint-disable */
|
||||
import * as Router from 'expo-router';
|
||||
|
||||
export * from 'expo-router';
|
||||
|
||||
declare module 'expo-router' {
|
||||
export namespace ExpoRouter {
|
||||
export interface __routes<T extends string | object = string> {
|
||||
hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/home` | `/home`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/notebooks` | `/notebooks`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/revision` | `/revision`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/search` | `/search`; params?: Router.UnknownInputParams; } | { pathname: `/note/create`; params?: Router.UnknownInputParams; } | { pathname: `/revision/session`; params?: Router.UnknownInputParams; } | { pathname: `/note/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `/notebook/[id]`, params: Router.UnknownInputParams & { id: string | number; } };
|
||||
hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/home` | `/home`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/notebooks` | `/notebooks`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/revision` | `/revision`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(tabs)'}/search` | `/search`; params?: Router.UnknownOutputParams; } | { pathname: `/note/create`; params?: Router.UnknownOutputParams; } | { pathname: `/revision/session`; params?: Router.UnknownOutputParams; } | { pathname: `/note/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `/notebook/[id]`, params: Router.UnknownOutputParams & { id: string; } };
|
||||
href: Router.RelativePathString | Router.ExternalPathString | `/_sitemap${`?${string}` | `#${string}` | ''}` | `${'/(auth)'}/login${`?${string}` | `#${string}` | ''}` | `/login${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/home${`?${string}` | `#${string}` | ''}` | `/home${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/notebooks${`?${string}` | `#${string}` | ''}` | `/notebooks${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/profile${`?${string}` | `#${string}` | ''}` | `/profile${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/revision${`?${string}` | `#${string}` | ''}` | `/revision${`?${string}` | `#${string}` | ''}` | `${'/(tabs)'}/search${`?${string}` | `#${string}` | ''}` | `/search${`?${string}` | `#${string}` | ''}` | `/note/create${`?${string}` | `#${string}` | ''}` | `/revision/session${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(auth)'}/login` | `/login`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/home` | `/home`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/notebooks` | `/notebooks`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/profile` | `/profile`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/revision` | `/revision`; params?: Router.UnknownInputParams; } | { pathname: `${'/(tabs)'}/search` | `/search`; params?: Router.UnknownInputParams; } | { pathname: `/note/create`; params?: Router.UnknownInputParams; } | { pathname: `/revision/session`; params?: Router.UnknownInputParams; } | `/note/${Router.SingleRoutePart<T>}${`?${string}` | `#${string}` | ''}` | `/notebook/${Router.SingleRoutePart<T>}${`?${string}` | `#${string}` | ''}` | { pathname: `/note/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `/notebook/[id]`, params: Router.UnknownInputParams & { id: string | number; } };
|
||||
}
|
||||
}
|
||||
}
|
||||
6
memento-mobile/.gitignore
vendored
Normal file
6
memento-mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
1
memento-mobile/.npmrc
Normal file
1
memento-mobile/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
@@ -21,11 +21,18 @@
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#FAFAF8"
|
||||
},
|
||||
"package": "com.momentonote.app"
|
||||
"package": "com.momentonote.app",
|
||||
"permissions": ["android.permission.RECORD_AUDIO"]
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-secure-store"
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-av",
|
||||
{
|
||||
"microphonePermission": "Momento a besoin du microphone pour la saisie vocale."
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -1,17 +1,54 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
View, Text, TextInput, TouchableOpacity,
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator,
|
||||
Alert, StyleSheet,
|
||||
} from 'react-native'
|
||||
import * as WebBrowser from 'expo-web-browser'
|
||||
import * as Linking from 'expo-linking'
|
||||
import { useAuthStore } from '@/lib/store'
|
||||
import { C } from '../_layout'
|
||||
import { API_URL } from '@/lib/config'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession()
|
||||
|
||||
export default function LoginScreen() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const [googleLoading, setGoogleLoading] = useState(false)
|
||||
const { login, loginWithToken } = useAuthStore()
|
||||
|
||||
// Traitement du deep link OAuth — mémorisé pour éviter les re-créations
|
||||
const handleOAuthCallback = useCallback(async (url: string) => {
|
||||
const parsed = Linking.parse(url)
|
||||
// memento://auth?token=... → scheme='memento', hostname='auth', path=''
|
||||
if (parsed.scheme !== 'memento' || parsed.hostname !== 'auth') return
|
||||
|
||||
const p = parsed.queryParams as Record<string, string> | undefined
|
||||
if (p?.error) {
|
||||
Alert.alert('Connexion Google échouée', p.error === 'unauthorized' ? 'Non autorisé' : 'Erreur serveur')
|
||||
setGoogleLoading(false)
|
||||
return
|
||||
}
|
||||
if (p?.token && p?.id) {
|
||||
await loginWithToken(p.token, {
|
||||
id: p.id,
|
||||
name: p.name || null,
|
||||
email: p.email || '',
|
||||
tier: p.tier || 'FREE',
|
||||
})
|
||||
}
|
||||
setGoogleLoading(false)
|
||||
}, [loginWithToken])
|
||||
|
||||
// Écoute le deep link (Android + app lancée via deep link)
|
||||
useEffect(() => {
|
||||
const sub = Linking.addEventListener('url', (event) => handleOAuthCallback(event.url))
|
||||
Linking.getInitialURL().then((url) => { if (url) handleOAuthCallback(url) })
|
||||
return () => sub.remove()
|
||||
}, [handleOAuthCallback])
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email.trim() || !password.trim()) return
|
||||
@@ -19,20 +56,63 @@ export default function LoginScreen() {
|
||||
try {
|
||||
await login(email.trim().toLowerCase(), password)
|
||||
} catch (e: any) {
|
||||
Alert.alert('Connexion échouée', e.message)
|
||||
Alert.alert(
|
||||
'Connexion échouée',
|
||||
e.message?.includes('Identifiants invalides')
|
||||
? 'Email ou mot de passe incorrect.\n\nSi vous vous êtes inscrit avec Google, utilisez le bouton Google ci-dessous.'
|
||||
: e.message
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogle = async () => {
|
||||
setGoogleLoading(true)
|
||||
try {
|
||||
const url = `${API_URL}/api/mobile/auth/google-start`
|
||||
const result = await WebBrowser.openAuthSessionAsync(url, 'memento://auth')
|
||||
if (result.type === 'success') {
|
||||
// iOS : le deep link est capturé dans result.url, pas via Linking.addEventListener
|
||||
await handleOAuthCallback(result.url)
|
||||
} else {
|
||||
// Annulé par l'utilisateur
|
||||
setGoogleLoading(false)
|
||||
}
|
||||
} catch (e: any) {
|
||||
Alert.alert('Erreur', e.message)
|
||||
setGoogleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={s.container}>
|
||||
<View style={s.inner}>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Bouton Google */}
|
||||
<TouchableOpacity onPress={handleGoogle} disabled={googleLoading || loading} style={s.googleBtn} activeOpacity={0.8}>
|
||||
{googleLoading
|
||||
? <ActivityIndicator color={C.ink} size="small" />
|
||||
: <>
|
||||
<Text style={s.googleG}>G</Text>
|
||||
<Text style={s.googleText}>Continuer avec Google</Text>
|
||||
</>}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Séparateur */}
|
||||
<View style={s.sep}>
|
||||
<View style={s.sepLine} />
|
||||
<Text style={s.sepText}>ou</Text>
|
||||
<View style={s.sepLine} />
|
||||
</View>
|
||||
|
||||
{/* Formulaire email/password */}
|
||||
<View style={s.form}>
|
||||
<Text style={s.label}>Email</Text>
|
||||
<TextInput
|
||||
@@ -41,13 +121,23 @@ export default function LoginScreen() {
|
||||
keyboardType="email-address" autoComplete="email"
|
||||
style={s.input} placeholderTextColor={C.concrete}
|
||||
/>
|
||||
|
||||
<Text style={[s.label, { marginTop: 16 }]}>Mot de passe</Text>
|
||||
<TextInput
|
||||
value={password} onChangeText={setPassword}
|
||||
placeholder="••••••••" secureTextEntry
|
||||
autoComplete="password" style={s.input}
|
||||
placeholderTextColor={C.concrete} onSubmitEditing={handleLogin}
|
||||
/>
|
||||
<View style={s.pwdRow}>
|
||||
<TextInput
|
||||
value={password} onChangeText={setPassword}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry={!showPwd}
|
||||
autoComplete="password"
|
||||
style={[s.input, { flex: 1, marginRight: 8 }]}
|
||||
placeholderTextColor={C.concrete}
|
||||
onSubmitEditing={handleLogin}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPwd(!showPwd)} style={s.eyeBtn}>
|
||||
<Text style={s.eyeIcon}>{showPwd ? '🙈' : '👁️'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleLogin}
|
||||
disabled={loading || !email || !password}
|
||||
@@ -68,14 +158,32 @@ export default function LoginScreen() {
|
||||
const s = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: C.paper },
|
||||
inner: { flex: 1, justifyContent: 'center', paddingHorizontal: 32 },
|
||||
logoBlock: { marginBottom: 48, alignItems: 'center' },
|
||||
logoBlock: { marginBottom: 40, alignItems: 'center' },
|
||||
logo: { fontSize: 36, fontStyle: 'italic', color: C.ink, fontWeight: '600' },
|
||||
tagline: { fontSize: 14, color: C.concrete, marginTop: 4 },
|
||||
|
||||
googleBtn: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
backgroundColor: C.white, borderWidth: 1.5, borderColor: C.border,
|
||||
borderRadius: 12, paddingVertical: 14,
|
||||
shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 3,
|
||||
},
|
||||
googleG: { fontSize: 18, fontWeight: '700', color: '#4285F4' },
|
||||
googleText: { fontSize: 15, fontWeight: '600', color: C.ink },
|
||||
|
||||
sep: { flexDirection: 'row', alignItems: 'center', marginVertical: 20, gap: 10 },
|
||||
sepLine: { flex: 1, height: 1, backgroundColor: C.border },
|
||||
sepText: { fontSize: 12, color: C.concrete, fontWeight: '500' },
|
||||
|
||||
form: {},
|
||||
label: { fontSize: 11, fontWeight: '700', letterSpacing: 1.5, textTransform: 'uppercase', color: C.concrete, marginBottom: 6 },
|
||||
input: { borderWidth: 1, borderColor: C.border, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 12, fontSize: 15, color: C.ink, backgroundColor: C.white },
|
||||
btn: { marginTop: 24, backgroundColor: C.ink, borderRadius: 12, paddingVertical: 14, alignItems: 'center' },
|
||||
btnDisabled: { opacity: 0.5 },
|
||||
input: { borderWidth: 1, borderColor: C.border, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13, fontSize: 15, color: C.ink, backgroundColor: C.white },
|
||||
pwdRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
eyeBtn: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 12 },
|
||||
eyeIcon: { fontSize: 18 },
|
||||
|
||||
btn: { marginTop: 20, backgroundColor: C.ink, borderRadius: 12, paddingVertical: 14, alignItems: 'center' },
|
||||
btnDisabled: { opacity: 0.4 },
|
||||
btnText: { color: C.white, fontWeight: '600', fontSize: 15 },
|
||||
footer: { textAlign: 'center', fontSize: 12, color: C.concrete, marginTop: 32 },
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Tabs } from 'expo-router'
|
||||
import { BookOpen, Search, Home, User } from 'lucide-react-native'
|
||||
import { C } from '../_layout'
|
||||
import { BookOpen, Search, Home, User, GraduationCap } from 'lucide-react-native'
|
||||
import { C } from '@/lib/theme'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
export default function TabsLayout() {
|
||||
return (
|
||||
@@ -13,10 +14,11 @@ export default function TabsLayout() {
|
||||
tabBarLabelStyle: { fontSize: 10, fontWeight: '600', marginTop: -2 },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen name="home" options={{ title: 'Accueil', tabBarIcon: ({ color, size }) => <Home size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="notebooks" options={{ title: 'Carnets', tabBarIcon: ({ color, size }) => <BookOpen size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="search" options={{ title: 'Recherche', tabBarIcon: ({ color, size }) => <Search size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="profile" options={{ title: 'Profil', tabBarIcon: ({ color, size }) => <User size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="home" options={{ title: t('tab.home'), tabBarIcon: ({ color, size }) => <Home size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="notebooks" options={{ title: t('tab.notebooks'), tabBarIcon: ({ color, size }) => <BookOpen size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="revision" options={{ title: t('tab.revision'), tabBarIcon: ({ color, size }) => <GraduationCap size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="search" options={{ title: t('tab.search'), tabBarIcon: ({ color, size }) => <Search size={size} color={color} /> }} />
|
||||
<Tabs.Screen name="profile" options={{ title: t('tab.profile'), tabBarIcon: ({ color, size }) => <User size={size} color={color} /> }} />
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
View, Text, ScrollView, TouchableOpacity,
|
||||
View, Text, ScrollView, TouchableOpacity, Alert,
|
||||
ActivityIndicator, RefreshControl, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { CalendarDays, Sparkles, BookOpen } from 'lucide-react-native'
|
||||
import { CalendarDays, PenLine, GraduationCap, Clock, ChevronRight, Search } from 'lucide-react-native'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { useAuthStore } from '@/lib/store'
|
||||
import { C } from '../_layout'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Note {
|
||||
id: string
|
||||
@@ -31,7 +31,7 @@ export default function HomeScreen() {
|
||||
const res = await apiFetch(ENDPOINTS.notes())
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRecentNotes((data.notes ?? []).slice(0, 10))
|
||||
setRecentNotes((data.notes ?? []).slice(0, 12))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -42,63 +42,95 @@ export default function HomeScreen() {
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const handleDailyNote = async () => {
|
||||
const res = await apiFetch(ENDPOINTS.dailyNote)
|
||||
if (res.ok) {
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.dailyNote)
|
||||
if (!res.ok) {
|
||||
Alert.alert('Erreur', 'Impossible de charger la note du jour.')
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
router.push(`/note/${data.id}`)
|
||||
const id = data.id ?? data.note?.id
|
||||
if (!id) { Alert.alert('Erreur', 'Note introuvable.'); return }
|
||||
router.push({ pathname: '/note/[id]', params: { id } })
|
||||
} catch {
|
||||
Alert.alert('Erreur réseau', 'Vérifiez votre connexion.')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
const now = new Date()
|
||||
const hour = now.getHours()
|
||||
const greeting = hour < 12 ? 'Bonjour' : hour < 18 ? 'Bon après-midi' : 'Bonsoir'
|
||||
const firstName = user?.name?.split(' ')[0] ?? ''
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.safe}>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load() }} tintColor={C.brand} />}
|
||||
>
|
||||
<View style={s.headerBlock}>
|
||||
<Text style={s.greeting}>
|
||||
Bonjour{user?.name ? `, ${user.name.split(' ')[0]}` : ''} 👋
|
||||
</Text>
|
||||
<Text style={s.date}>
|
||||
{new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</Text>
|
||||
{/* Header */}
|
||||
<View style={s.header}>
|
||||
<View>
|
||||
<Text style={s.greeting}>{greeting}{firstName ? `, ${firstName}` : ''}</Text>
|
||||
<Text style={s.date}>{now.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => router.push({ pathname: '/(tabs)/search' })} style={s.searchBtn}>
|
||||
<Search size={18} color={C.ink} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Quick actions — actions uniques, pas de doublons avec le tab bar */}
|
||||
<View style={s.quickRow}>
|
||||
<TouchableOpacity onPress={handleDailyNote} style={s.quickBtn}>
|
||||
<CalendarDays size={22} color={C.brand} />
|
||||
<TouchableOpacity onPress={handleDailyNote} style={s.quickCard} activeOpacity={0.7}>
|
||||
<View style={[s.quickIcon, { backgroundColor: '#f3ece4' }]}>
|
||||
<CalendarDays size={20} color={C.brand} />
|
||||
</View>
|
||||
<Text style={s.quickLabel}>Note du jour</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => router.push('/(tabs)/notebooks')} style={s.quickBtn}>
|
||||
<BookOpen size={22} color={C.brand} />
|
||||
<Text style={s.quickLabel}>Carnets</Text>
|
||||
<TouchableOpacity onPress={() => router.push({ pathname: '/note/create' })} style={s.quickCard} activeOpacity={0.7}>
|
||||
<View style={[s.quickIcon, { backgroundColor: '#edf0f7' }]}>
|
||||
<PenLine size={20} color="#5b7ec7" />
|
||||
</View>
|
||||
<Text style={s.quickLabel}>Nouvelle note</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => router.push('/(tabs)/search')} style={s.quickBtn}>
|
||||
<Sparkles size={22} color={C.brand} />
|
||||
<Text style={s.quickLabel}>Recherche IA</Text>
|
||||
<TouchableOpacity onPress={() => router.push({ pathname: '/(tabs)/revision' })} style={s.quickCard} activeOpacity={0.7}>
|
||||
<View style={[s.quickIcon, { backgroundColor: '#eef7ed' }]}>
|
||||
<GraduationCap size={20} color="#4a9b61" />
|
||||
</View>
|
||||
<Text style={s.quickLabel}>Révision</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={s.recentBlock}>
|
||||
<Text style={s.sectionLabel}>Récentes</Text>
|
||||
{/* Recent notes */}
|
||||
<View style={s.section}>
|
||||
<View style={s.sectionHeader}>
|
||||
<Clock size={13} color={C.concrete} />
|
||||
<Text style={s.sectionLabel}>Récentes</Text>
|
||||
</View>
|
||||
|
||||
{loading
|
||||
? <ActivityIndicator color={C.brand} />
|
||||
? <ActivityIndicator color={C.brand} style={{ marginTop: 24 }} />
|
||||
: recentNotes.length === 0
|
||||
? <Text style={s.empty}>Aucune note pour l'instant.</Text>
|
||||
: recentNotes.map((note) => (
|
||||
<TouchableOpacity key={note.id} onPress={() => router.push(`/note/${note.id}`)} style={s.noteCard}>
|
||||
<Text style={s.noteTitle} numberOfLines={1}>{note.title || 'Sans titre'}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 4 }}>
|
||||
{note.notebookName && <Text style={s.noteMeta}>{note.notebookName}</Text>}
|
||||
<Text style={s.noteMeta}>{formatDate(note.updatedAt)}</Text>
|
||||
: recentNotes.map((note, i) => (
|
||||
<TouchableOpacity
|
||||
key={note.id}
|
||||
onPress={() => router.push({ pathname: '/note/[id]', params: { id: note.id } })}
|
||||
style={[s.noteRow, i === recentNotes.length - 1 && { borderBottomWidth: 0 }]}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={s.noteTitle} numberOfLines={1}>{note.title || 'Sans titre'}</Text>
|
||||
{note.notebookName && <Text style={s.noteMeta} numberOfLines={1}>{note.notebookName}</Text>}
|
||||
</View>
|
||||
<View style={s.noteRight}>
|
||||
<Text style={s.noteDate}>{new Date(note.updatedAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}</Text>
|
||||
<ChevronRight size={12} color={C.border} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<View style={{ height: 32 }} />
|
||||
<View style={{ height: 40 }} />
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
@@ -106,16 +138,21 @@ export default function HomeScreen() {
|
||||
|
||||
const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: C.paper },
|
||||
headerBlock: { paddingHorizontal: 20, paddingTop: 16, paddingBottom: 8 },
|
||||
greeting: { fontSize: 22, fontWeight: '700', fontStyle: 'italic', color: C.ink },
|
||||
date: { fontSize: 13, color: C.concrete, marginTop: 2 },
|
||||
quickRow: { flexDirection: 'row', gap: 10, paddingHorizontal: 20, marginTop: 16 },
|
||||
quickBtn: { flex: 1, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 16, padding: 14, alignItems: 'center', gap: 8 },
|
||||
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', paddingHorizontal: 20, paddingTop: 20, paddingBottom: 16 },
|
||||
greeting: { fontSize: 22, fontWeight: '700', color: C.ink, letterSpacing: -0.3 },
|
||||
date: { fontSize: 13, color: C.concrete, marginTop: 3, textTransform: 'capitalize' },
|
||||
searchBtn: { width: 38, height: 38, borderRadius: 19, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, alignItems: 'center', justifyContent: 'center' },
|
||||
quickRow: { flexDirection: 'row', gap: 10, paddingHorizontal: 20, marginBottom: 24 },
|
||||
quickCard: { flex: 1, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 14, padding: 14, alignItems: 'center', gap: 10 },
|
||||
quickIcon: { width: 40, height: 40, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
|
||||
quickLabel: { fontSize: 11, fontWeight: '600', color: C.ink, textAlign: 'center' },
|
||||
recentBlock: { paddingHorizontal: 20, marginTop: 24 },
|
||||
sectionLabel: { fontSize: 10, fontWeight: '800', letterSpacing: 2, textTransform: 'uppercase', color: C.concrete, marginBottom: 12 },
|
||||
empty: { color: C.concrete, fontSize: 14 },
|
||||
noteCard: { backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 16, padding: 16, marginBottom: 10 },
|
||||
noteTitle: { fontSize: 15, fontWeight: '600', color: C.ink },
|
||||
noteMeta: { fontSize: 11, color: C.concrete },
|
||||
section: { marginHorizontal: 20, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 16, overflow: 'hidden' },
|
||||
sectionHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: C.border, backgroundColor: '#f8f6f2' },
|
||||
sectionLabel: { fontSize: 11, fontWeight: '700', color: C.concrete, textTransform: 'uppercase', letterSpacing: 1 },
|
||||
empty: { color: C.concrete, fontSize: 14, textAlign: 'center', padding: 24 },
|
||||
noteRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 13, borderBottomWidth: 1, borderBottomColor: C.border },
|
||||
noteTitle: { fontSize: 14, fontWeight: '500', color: C.ink },
|
||||
noteMeta: { fontSize: 11, color: C.concrete, marginTop: 2 },
|
||||
noteRight: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||
noteDate: { fontSize: 11, color: C.concrete },
|
||||
})
|
||||
|
||||
@@ -5,18 +5,31 @@ import {
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronRight } from 'lucide-react-native'
|
||||
import { ChevronRight, BookOpen, Folder, Plus } from 'lucide-react-native'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { C } from '../_layout'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Notebook {
|
||||
id: string
|
||||
name: string
|
||||
icon: string | null
|
||||
color: string | null
|
||||
_count: { notes: number }
|
||||
}
|
||||
|
||||
// icônes Lucide stockées en string → afficher composant, sinon emoji
|
||||
const LUCIDE_ICONS = new Set(['folder','book','archive','bookmark','file','note','inbox'])
|
||||
|
||||
function NotebookIcon({ icon, color }: { icon: string | null, color: string | null }) {
|
||||
const tint = color || C.brand
|
||||
if (!icon || LUCIDE_ICONS.has(icon.toLowerCase())) {
|
||||
return <Folder size={18} color={tint} />
|
||||
}
|
||||
// emoji ou autre caractère unicode
|
||||
return <Text style={{ fontSize: 18, lineHeight: 22 }}>{icon}</Text>
|
||||
}
|
||||
|
||||
export default function NotebooksScreen() {
|
||||
const [notebooks, setNotebooks] = useState<Notebook[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -49,7 +62,15 @@ export default function NotebooksScreen() {
|
||||
return (
|
||||
<SafeAreaView style={s.safe}>
|
||||
<View style={s.header}>
|
||||
<BookOpen size={18} color={C.brand} style={{ marginRight: 8 }} />
|
||||
<Text style={s.title}>Carnets</Text>
|
||||
<Text style={s.count}>{notebooks.length}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push({ pathname: '/note/create' })}
|
||||
style={s.newNoteBtn}
|
||||
>
|
||||
<Plus size={18} color={C.brand} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FlatList
|
||||
data={notebooks}
|
||||
@@ -57,16 +78,23 @@ export default function NotebooksScreen() {
|
||||
contentContainerStyle={s.list}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load() }} tintColor={C.brand} />}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity onPress={() => router.push(`/notebook/${item.id}`)} style={s.card}>
|
||||
<Text style={s.icon}>{item.icon ?? '📓'}</Text>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={s.cardTitle}>{item.name}</Text>
|
||||
<Text style={s.cardMeta}>{item._count?.notes ?? 0} notes</Text>
|
||||
<TouchableOpacity onPress={() => router.push({ pathname: '/notebook/[id]', params: { id: item.id } })} style={s.card} activeOpacity={0.7}>
|
||||
<View style={[s.iconWrap, { backgroundColor: (item.color || C.brand) + '18' }]}>
|
||||
<NotebookIcon icon={item.icon} color={item.color} />
|
||||
</View>
|
||||
<ChevronRight size={16} color={C.concrete} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={s.cardTitle} numberOfLines={1}>{item.name}</Text>
|
||||
<Text style={s.cardMeta}>{item._count?.notes ?? 0} note{(item._count?.notes ?? 0) !== 1 ? 's' : ''}</Text>
|
||||
</View>
|
||||
<ChevronRight size={14} color={C.border} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ListEmptyComponent={<Text style={s.empty}>Aucun carnet.</Text>}
|
||||
ListEmptyComponent={
|
||||
<View style={s.emptyWrap}>
|
||||
<BookOpen size={32} color={C.border} />
|
||||
<Text style={s.empty}>Aucun carnet</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
@@ -74,12 +102,15 @@ export default function NotebooksScreen() {
|
||||
|
||||
const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: C.paper },
|
||||
header: { paddingHorizontal: 20, paddingTop: 16, paddingBottom: 8 },
|
||||
title: { fontSize: 22, fontStyle: 'italic', fontWeight: '700', color: C.ink },
|
||||
list: { paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32 },
|
||||
card: { flexDirection: 'row', alignItems: 'center', backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 16, padding: 16, marginBottom: 10 },
|
||||
icon: { fontSize: 24, marginRight: 12 },
|
||||
cardTitle: { fontSize: 15, fontWeight: '600', color: C.ink },
|
||||
cardMeta: { fontSize: 12, color: C.concrete, marginTop: 2 },
|
||||
empty: { textAlign: 'center', color: C.concrete, marginTop: 48 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingTop: 16, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: C.border },
|
||||
title: { fontSize: 20, fontWeight: '700', color: C.ink, flex: 1 },
|
||||
count: { fontSize: 12, color: C.concrete, backgroundColor: C.border, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, overflow: 'hidden', marginRight: 8 },
|
||||
newNoteBtn: { width: 34, height: 34, borderRadius: 17, backgroundColor: '#f3ece4', alignItems: 'center', justifyContent: 'center' },
|
||||
list: { padding: 12 },
|
||||
card: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 14, padding: 14, marginBottom: 8 },
|
||||
iconWrap: { width: 38, height: 38, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
|
||||
cardTitle: { fontSize: 14, fontWeight: '600', color: C.ink, marginBottom: 2 },
|
||||
cardMeta: { fontSize: 12, color: C.concrete },
|
||||
emptyWrap: { alignItems: 'center', marginTop: 60, gap: 12 },
|
||||
empty: { color: C.concrete, fontSize: 14 },
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { View, Text, TouchableOpacity, ScrollView, Alert, StyleSheet } from 'rea
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { LogOut, CreditCard, Globe } from 'lucide-react-native'
|
||||
import { useAuthStore } from '@/lib/store'
|
||||
import { C } from '../_layout'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
FREE: 'Gratuit',
|
||||
|
||||
174
memento-mobile/app/(tabs)/revision.tsx
Normal file
174
memento-mobile/app/(tabs)/revision.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
View, Text, StyleSheet, FlatList, TouchableOpacity,
|
||||
ActivityIndicator, RefreshControl,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { GraduationCap, BookOpen, ChevronRight, Clock } from 'lucide-react-native'
|
||||
import { C } from '@/lib/theme'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
|
||||
interface Deck {
|
||||
id: string
|
||||
name: string
|
||||
notebookName: string | null
|
||||
totalCards: number
|
||||
dueCount: number
|
||||
masteredCount: number
|
||||
}
|
||||
|
||||
export default function RevisionScreen() {
|
||||
const router = useRouter()
|
||||
const [decks, setDecks] = useState<Deck[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.flashcardDecks)
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error ?? `Erreur ${res.status}`)
|
||||
setDecks(data.decks ?? [])
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? 'Erreur de chargement')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const totalDue = decks.reduce((s, d) => s + d.dueCount, 0)
|
||||
|
||||
if (loading) return (
|
||||
<SafeAreaView style={s.center}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
</SafeAreaView>
|
||||
)
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.safe}>
|
||||
{/* Header */}
|
||||
<View style={s.header}>
|
||||
<View style={s.headerLeft}>
|
||||
<GraduationCap size={22} color={C.brand} />
|
||||
<Text style={s.title}>Révision</Text>
|
||||
</View>
|
||||
{totalDue > 0 && (
|
||||
<View style={s.badge}>
|
||||
<Text style={s.badgeTxt}>{totalDue} à revoir</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<View style={s.center}>
|
||||
<Text style={s.errorTxt}>{error}</Text>
|
||||
<TouchableOpacity onPress={() => load()} style={s.retryBtn}>
|
||||
<Text style={s.retryTxt}>Réessayer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : decks.length === 0 ? (
|
||||
<View style={s.center}>
|
||||
<GraduationCap size={48} color={C.concrete} />
|
||||
<Text style={s.emptyTitle}>Aucun paquet</Text>
|
||||
<Text style={s.emptyHint}>
|
||||
Générez des flashcards depuis une note (bouton ✦ dans l'éditeur) pour commencer à réviser.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={decks}
|
||||
keyExtractor={(d) => d.id}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load(true) }} tintColor={C.brand} />}
|
||||
contentContainerStyle={s.list}
|
||||
renderItem={({ item }) => <DeckCard deck={item} onPress={() => router.push({ pathname: '/revision/session', params: { deckId: item.id, deckName: item.name } })} />}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
function DeckCard({ deck, onPress }: { deck: Deck; onPress: () => void }) {
|
||||
const progress = deck.totalCards > 0 ? deck.masteredCount / deck.totalCards : 0
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={s.card} onPress={onPress} activeOpacity={0.8}>
|
||||
<View style={s.cardLeft}>
|
||||
<View style={s.deckIcon}>
|
||||
<BookOpen size={18} color={C.brand} />
|
||||
</View>
|
||||
<View style={s.cardInfo}>
|
||||
<Text style={s.deckName} numberOfLines={1}>{deck.name}</Text>
|
||||
{deck.notebookName && <Text style={s.deckSub} numberOfLines={1}>{deck.notebookName}</Text>}
|
||||
<View style={s.progressBar}>
|
||||
<View style={[s.progressFill, { width: `${Math.round(progress * 100)}%` }]} />
|
||||
</View>
|
||||
<Text style={s.progressTxt}>
|
||||
{deck.masteredCount}/{deck.totalCards} maîtrisées
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={s.cardRight}>
|
||||
{deck.dueCount > 0 ? (
|
||||
<View style={s.duePill}>
|
||||
<Clock size={11} color="#e11d48" />
|
||||
<Text style={s.dueTxt}>{deck.dueCount}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={s.upToDate}>✓</Text>
|
||||
)}
|
||||
<ChevronRight size={16} color={C.concrete} style={{ marginTop: 4 }} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: C.paper },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
|
||||
header: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingHorizontal: 20, paddingTop: 12, paddingBottom: 16,
|
||||
borderBottomWidth: 1, borderBottomColor: C.border,
|
||||
},
|
||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
title: { fontSize: 20, fontWeight: '700', color: C.ink },
|
||||
badge: { backgroundColor: '#fee2e2', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 20 },
|
||||
badgeTxt: { fontSize: 12, fontWeight: '600', color: '#e11d48' },
|
||||
list: { padding: 16, gap: 10 },
|
||||
card: {
|
||||
backgroundColor: C.surface, borderRadius: 14, padding: 14,
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
borderWidth: 1, borderColor: C.border,
|
||||
},
|
||||
cardLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 12, marginRight: 8 },
|
||||
deckIcon: {
|
||||
width: 40, height: 40, borderRadius: 12,
|
||||
backgroundColor: '#f0f7ff', alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
cardInfo: { flex: 1 },
|
||||
deckName: { fontSize: 15, fontWeight: '600', color: C.ink, marginBottom: 2 },
|
||||
deckSub: { fontSize: 12, color: C.concrete, marginBottom: 6 },
|
||||
progressBar: { height: 4, backgroundColor: C.border, borderRadius: 2, marginBottom: 4 },
|
||||
progressFill: { height: 4, backgroundColor: C.brand, borderRadius: 2 },
|
||||
progressTxt: { fontSize: 11, color: C.concrete },
|
||||
cardRight: { alignItems: 'flex-end', gap: 2 },
|
||||
duePill: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: 3,
|
||||
backgroundColor: '#fee2e2', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10,
|
||||
},
|
||||
dueTxt: { fontSize: 12, fontWeight: '700', color: '#e11d48' },
|
||||
upToDate: { fontSize: 16, color: C.brand, fontWeight: '700' },
|
||||
emptyTitle: { fontSize: 18, fontWeight: '700', color: C.ink, marginTop: 16, marginBottom: 8 },
|
||||
emptyHint: { fontSize: 14, color: C.concrete, textAlign: 'center', lineHeight: 20 },
|
||||
errorTxt: { fontSize: 15, color: '#e11d48', textAlign: 'center', marginBottom: 12 },
|
||||
retryBtn: { backgroundColor: C.brand, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 },
|
||||
retryTxt: { color: '#fff', fontWeight: '600' },
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import { Search as SearchIcon, X } from 'lucide-react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { C } from '../_layout'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface SearchResult {
|
||||
id: string
|
||||
@@ -67,7 +67,7 @@ export default function SearchScreen() {
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={s.list}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity onPress={() => router.push(`/note/${item.id}`)} style={s.card}>
|
||||
<TouchableOpacity onPress={() => router.push({ pathname: '/note/[id]', params: { id: item.id } })} style={s.card}>
|
||||
<Text style={s.cardTitle} numberOfLines={1}>{item.title || 'Sans titre'}</Text>
|
||||
{item.snippet && <Text style={s.snippet} numberOfLines={2}>{item.snippet}</Text>}
|
||||
{item.notebookName && <Text style={s.nb}>{item.notebookName}</Text>}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { View, ActivityIndicator, StyleSheet } from 'react-native'
|
||||
import { useAuthStore } from '@/lib/store'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
// Ré-exporter C pour la compatibilité avec les anciens imports
|
||||
export { C } from '@/lib/theme'
|
||||
|
||||
export default function RootLayout() {
|
||||
const { user, loading, restore } = useAuthStore()
|
||||
@@ -15,15 +19,17 @@ export default function RootLayout() {
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
const inAuth = segments[0] === '(auth)'
|
||||
if (!user && !inAuth) router.replace('/(auth)/login')
|
||||
else if (user && inAuth) router.replace('/(tabs)/home')
|
||||
if (!user && !inAuth) router.replace({ pathname: '/(auth)/login' })
|
||||
else if (user && inAuth) router.replace({ pathname: '/(tabs)/home' })
|
||||
}, [user, loading, segments])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={s.loader}>
|
||||
<ActivityIndicator size="large" color={C.brand} />
|
||||
</View>
|
||||
<SafeAreaProvider>
|
||||
<View style={s.loader}>
|
||||
<ActivityIndicator size="large" color={C.brand} />
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,18 +41,6 @@ export default function RootLayout() {
|
||||
)
|
||||
}
|
||||
|
||||
export const C = {
|
||||
brand: '#A47148',
|
||||
ink: '#1A1A18',
|
||||
paper: '#FAFAF8',
|
||||
concrete: '#8A8A82',
|
||||
border: '#E8E6E0',
|
||||
white: '#FFFFFF',
|
||||
rose: '#e11d48',
|
||||
roseBg: '#fff1f2',
|
||||
roseBorder: '#fecdd3',
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
loader: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: C.paper },
|
||||
})
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
View, Text, ActivityIndicator,
|
||||
TouchableOpacity, Share, StyleSheet,
|
||||
TouchableOpacity, Share, Alert,
|
||||
TextInput, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ArrowLeft, Share2 } from 'lucide-react-native'
|
||||
import { ArrowLeft, Share2, Pencil, Check, Trash2, GraduationCap } from 'lucide-react-native'
|
||||
import { WebView } from 'react-native-webview'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { C } from '../_layout'
|
||||
import { C } from '@/lib/theme'
|
||||
import { AISheet } from '@/components/AISheet'
|
||||
import { MicButton } from '@/components/MicButton'
|
||||
import { FlashcardSheet } from '@/components/FlashcardSheet'
|
||||
import { useAudioRecorder } from '@/lib/useAudioRecorder'
|
||||
|
||||
interface Note {
|
||||
id: string
|
||||
@@ -19,31 +24,107 @@ interface Note {
|
||||
notebookName?: string
|
||||
}
|
||||
|
||||
const AI_MODES = [
|
||||
{ key: 'improve', label: '✨ Améliorer le style' },
|
||||
{ key: 'fix_grammar', label: '🔤 Corriger la grammaire' },
|
||||
{ key: 'shorten', label: '✂️ Raccourcir' },
|
||||
{ key: 'clarify', label: '💡 Clarifier' },
|
||||
]
|
||||
|
||||
/** Extrait le texte brut depuis le HTML TipTap */
|
||||
function htmlToPlainText(html: string): string {
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/p>/gi, '\n')
|
||||
.replace(/<\/h[1-6]>/gi, '\n')
|
||||
.replace(/<\/li>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function tipTapToHtml(node: any): string {
|
||||
if (!node) return ''
|
||||
if (node.type === 'doc') return (node.content ?? []).map(tipTapToHtml).join('')
|
||||
if (node.type === 'text') {
|
||||
let t = (node.text ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
if (node.marks?.some((m: any) => m.type === 'bold')) t = `<strong>${t}</strong>`
|
||||
if (node.marks?.some((m: any) => m.type === 'italic')) t = `<em>${t}</em>`
|
||||
if (node.marks?.some((m: any) => m.type === 'code')) t = `<code>${t}</code>`
|
||||
return t
|
||||
}
|
||||
const inner = (node.content ?? []).map(tipTapToHtml).join('')
|
||||
switch (node.type) {
|
||||
case 'paragraph': return `<p>${inner || '​'}</p>`
|
||||
case 'heading': return `<h${node.attrs?.level ?? 1}>${inner}</h${node.attrs?.level ?? 1}>`
|
||||
case 'bulletList': return `<ul>${inner}</ul>`
|
||||
case 'orderedList': return `<ol>${inner}</ol>`
|
||||
case 'listItem': return `<li>${inner}</li>`
|
||||
case 'blockquote': return `<blockquote>${inner}</blockquote>`
|
||||
case 'codeBlock': return `<pre><code>${inner}</code></pre>`
|
||||
case 'hardBreak': return '<br>'
|
||||
default: return inner
|
||||
}
|
||||
}
|
||||
|
||||
function buildHtml(content: string, title: string) {
|
||||
const safeTitle = title.replace(/</g, '<').replace(/>/g, '>')
|
||||
let body: string
|
||||
const trimmed = content.trimStart()
|
||||
if (trimmed.startsWith('<')) {
|
||||
body = content
|
||||
} else if (trimmed.startsWith('{')) {
|
||||
try { body = tipTapToHtml(JSON.parse(content)) } catch { body = `<p>${content.replace(/\n/g, '<br>')}</p>` }
|
||||
} else {
|
||||
body = `<p>${content.replace(/\n/g, '<br>')}</p>`
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=3">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, sans-serif; font-size: 16px; line-height: 1.7;
|
||||
color: #1A1A18; background: #FAFAF8; padding: 0 16px 32px; }
|
||||
h1 { font-size: 22px; font-weight: 700; margin: 20px 0 12px; }
|
||||
h2 { font-size: 18px; font-weight: 700; margin: 16px 0 10px; }
|
||||
h3 { font-size: 16px; font-weight: 600; margin: 14px 0 8px; }
|
||||
:root { --brand: #A47148; --ink: #1A1A18; --paper: #FAFAF8; --concrete: #8A8A82; --border: #E8E6E0; --code-bg: #f0ede8; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-text-size-adjust: 100%; }
|
||||
body { font-family: -apple-system, 'Helvetica Neue', sans-serif; font-size: 16px; line-height: 1.75; color: var(--ink); background: var(--paper); padding: 20px 20px 80px; word-break: break-word; }
|
||||
.note-title { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; color: var(--ink); margin-bottom: 4px; line-height: 1.3; }
|
||||
.note-sep { border: none; border-top: 1px solid var(--border); margin: 16px 0 20px; }
|
||||
h1 { font-size: 22px; font-weight: 700; margin: 24px 0 10px; }
|
||||
h2 { font-size: 19px; font-weight: 700; margin: 20px 0 8px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
|
||||
h3 { font-size: 16px; font-weight: 600; margin: 16px 0 6px; }
|
||||
p { margin: 0 0 12px; }
|
||||
ul, ol { padding-left: 20px; margin: 0 0 12px; }
|
||||
ul, ol { padding-left: 24px; margin: 0 0 12px; }
|
||||
li { margin-bottom: 4px; }
|
||||
blockquote { border-left: 3px solid #A47148; padding-left: 12px; color: #666; margin: 12px 0; }
|
||||
code { background: #f0ede8; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
||||
pre { background: #f0ede8; padding: 12px; border-radius: 8px; overflow: auto; margin: 12px 0; }
|
||||
a { color: #A47148; }
|
||||
img { max-width: 100%; border-radius: 8px; margin: 8px 0; }
|
||||
strong { font-weight: 700; }
|
||||
em { font-style: italic; }
|
||||
li::marker { color: var(--brand); }
|
||||
blockquote { border-left: 3px solid var(--brand); margin: 16px 0; padding: 10px 14px; background: #f7f2ec; border-radius: 0 10px 10px 0; color: #5a5a52; font-style: italic; }
|
||||
blockquote p { margin: 0; }
|
||||
code { background: var(--code-bg); padding: 2px 7px; border-radius: 6px; font-size: 13px; font-family: 'Menlo', 'Courier New', monospace; color: var(--brand); }
|
||||
pre { background: #1e1e1c; padding: 16px; border-radius: 12px; overflow-x: auto; margin: 16px 0; }
|
||||
pre code { background: none; padding: 0; color: #e8e6e0; font-size: 13px; line-height: 1.6; }
|
||||
a { color: var(--brand); text-decoration: none; border-bottom: 1px solid #d4b896; }
|
||||
img { max-width: 100%; border-radius: 12px; margin: 12px 0; display: block; }
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 14px; }
|
||||
th { background: var(--code-bg); font-weight: 700; padding: 10px 12px; text-align: left; border-bottom: 2px solid var(--border); }
|
||||
td { padding: 9px 12px; border-bottom: 1px solid var(--border); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
strong, b { font-weight: 700; }
|
||||
em, i { font-style: italic; }
|
||||
del, s { text-decoration: line-through; color: var(--concrete); }
|
||||
mark { background: #fff3cd; padding: 1px 3px; border-radius: 3px; }
|
||||
input[type=checkbox] { accent-color: var(--brand); margin-right: 6px; width: 16px; height: 16px; }
|
||||
ul[data-type="taskList"] { list-style: none; padding-left: 4px; }
|
||||
ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 8px; }
|
||||
ul[data-type="taskList"] li > label { margin-top: 2px; }
|
||||
[data-type="liveBlock"], [data-type="structuredViewBlock"] { border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; margin: 8px 0; background: #f8f6f2; color: var(--concrete); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${content}</body>
|
||||
<body>
|
||||
<div class="note-title">${safeTitle}</div>
|
||||
<hr class="note-sep">
|
||||
<div id="content">${body}</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
@@ -51,46 +132,180 @@ export default function NoteScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const [note, setNote] = useState<Note | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [editContent, setEditContent] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [aiSheetOpen, setAiSheetOpen] = useState(false)
|
||||
const [flashcardSheetOpen, setFlashcardSheetOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
// Audio en mode édition
|
||||
const { state: audioState, startRecording, stopAndTranscribe, cancelRecording } = useAudioRecorder(
|
||||
(text) => setEditContent((prev) => prev ? prev + ' ' + text : text)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch(ENDPOINTS.note(id))
|
||||
.then((r) => r.json())
|
||||
if (!id) return
|
||||
apiFetch(ENDPOINTS.note(id as string))
|
||||
.then((r) => { if (!r.ok) throw new Error(`Erreur ${r.status}`); return r.json() })
|
||||
.then((data) => setNote(data.note ?? null))
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!note) return
|
||||
setEditTitle(note.title)
|
||||
setEditMode(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!note || !editTitle.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.note(note.id), {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ title: editTitle.trim() }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Erreur de sauvegarde')
|
||||
setNote((prev) => prev ? { ...prev, title: editTitle.trim() } : prev)
|
||||
setEditMode(false)
|
||||
} catch (e: any) {
|
||||
Alert.alert('Erreur', e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMic = () => {
|
||||
if (audioState === 'idle' || audioState === 'error') startRecording()
|
||||
else if (audioState === 'recording') stopAndTranscribe()
|
||||
else cancelRecording()
|
||||
}
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!note) return
|
||||
await Share.share({ title: note.title, message: `${note.title}\nhttps://memento-note.com` })
|
||||
const publicUrl = note.publicSlug ? `https://memento-note.com/p/${note.publicSlug}` : null
|
||||
const plain = note.content?.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) || ''
|
||||
const message = publicUrl
|
||||
? `${note.title}\n\n${plain}...\n\nLire: ${publicUrl}`
|
||||
: `${note.title}\n\n${plain}${plain.length === 200 ? '...' : ''}`
|
||||
await Share.share({ title: note.title, message })
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!note) return
|
||||
Alert.alert(
|
||||
'Supprimer la note',
|
||||
`Mettre "${note.title}" à la corbeille ?`,
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer', style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.note(note.id), { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`Erreur ${res.status}`)
|
||||
router.back()
|
||||
} catch (e: any) {
|
||||
Alert.alert('Erreur', e.message ?? 'Impossible de supprimer la note')
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.safe}>
|
||||
<View style={s.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={s.backBtn}>
|
||||
<TouchableOpacity onPress={() => { if (editMode) setEditMode(false); else router.back() }} style={s.backBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<ArrowLeft size={22} color={C.ink} />
|
||||
</TouchableOpacity>
|
||||
<Text style={s.headerTitle} numberOfLines={1}>{note?.title ?? '…'}</Text>
|
||||
<TouchableOpacity onPress={handleShare} style={s.shareBtn}>
|
||||
<Share2 size={18} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
<Text style={s.headerTitle} numberOfLines={1}>{editMode ? (editTitle || '…') : (note?.title ?? '…')}</Text>
|
||||
{note && !editMode && (
|
||||
<>
|
||||
<TouchableOpacity onPress={handleEdit} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Pencil size={18} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setFlashcardSheetOpen(true)} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<GraduationCap size={18} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleShare} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Share2 size={18} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleDelete} style={s.iconBtn} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Trash2 size={18} color="#e11d48" />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
{editMode && (
|
||||
<TouchableOpacity onPress={handleSave} disabled={saving || !editTitle.trim()} style={[s.saveBtn, (!editTitle.trim() || saving) && { opacity: 0.35 }]}>
|
||||
{saving ? <ActivityIndicator size="small" color={C.white} /> : <Check size={18} color={C.white} />}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{loading
|
||||
? <View style={s.center}><ActivityIndicator color={C.brand} size="large" /></View>
|
||||
: note
|
||||
? <WebView source={{ html: buildHtml(note.content, note.title) }} style={{ flex: 1, backgroundColor: C.paper }} scrollEnabled showsVerticalScrollIndicator={false} />
|
||||
: <View style={s.center}><Text style={{ color: C.concrete }}>Note introuvable.</Text></View>}
|
||||
{loading && <View style={s.center}><ActivityIndicator color={C.brand} size="large" /></View>}
|
||||
{error && <View style={s.center}><Text style={{ color: '#e11d48' }}>{error}</Text></View>}
|
||||
{!loading && !error && !note && <View style={s.center}><Text style={{ color: C.concrete }}>Note introuvable.</Text></View>}
|
||||
|
||||
{/* Mode lecture */}
|
||||
{note && !editMode && (
|
||||
<WebView
|
||||
source={{ html: buildHtml(note.content ?? '', note.title ?? '') }}
|
||||
style={{ flex: 1, backgroundColor: C.paper }}
|
||||
javaScriptEnabled scrollEnabled showsVerticalScrollIndicator={false} originWhitelist={['*']}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mode édition — titre uniquement, contenu preserve HTML */}
|
||||
{note && editMode && (
|
||||
<View style={{ flex: 1 }}>
|
||||
<TextInput value={editTitle} onChangeText={setEditTitle} style={s.editTitle} placeholder="Titre…" placeholderTextColor={C.border} autoFocus />
|
||||
<View style={s.editDivider} />
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 20 }}>
|
||||
<Text style={{ fontSize: 14, color: C.concrete, textAlign: 'center', lineHeight: 22 }}>
|
||||
Le contenu enrichi ne peut pas être édité depuis mobile.{`\n\n`}Utilisez la version web pour modifier le texte.
|
||||
</Text>
|
||||
</View>
|
||||
{/* Barre outils */}
|
||||
<View style={s.toolbar}>
|
||||
<TouchableOpacity onPress={handleSave} disabled={saving} style={[s.aiBtn, { flex: 1, justifyContent: 'center' }]} activeOpacity={0.8}>
|
||||
<Text style={s.aiBtnText}>{saving ? 'Sauvegarde…' : 'Enregistrer le titre'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<AISheet visible={aiSheetOpen} onClose={() => setAiSheetOpen(false)} text={editContent} onApply={(t) => setEditContent(t)} />
|
||||
{note && (
|
||||
<FlashcardSheet
|
||||
visible={flashcardSheetOpen}
|
||||
onClose={() => setFlashcardSheetOpen(false)}
|
||||
noteId={note.id}
|
||||
noteTitle={note.title}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: C.paper },
|
||||
header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: C.border },
|
||||
header: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: C.border, backgroundColor: C.paper },
|
||||
backBtn: { padding: 4 },
|
||||
headerTitle: { flex: 1, fontSize: 15, fontWeight: '600', color: C.ink },
|
||||
shareBtn: { padding: 4 },
|
||||
iconBtn: { padding: 4 },
|
||||
saveBtn: { backgroundColor: C.brand, padding: 7, borderRadius: 10 },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
editTitle: { fontSize: 24, fontWeight: '800', color: C.ink, paddingHorizontal: 20, paddingTop: 16, paddingBottom: 8, letterSpacing: -0.5 },
|
||||
editDivider: { height: 1, backgroundColor: C.border, marginHorizontal: 20, marginBottom: 12 },
|
||||
editContent: { flex: 1, fontSize: 16, color: C.ink, lineHeight: 26, paddingHorizontal: 20, paddingBottom: 80 },
|
||||
toolbar: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 12, borderTopWidth: 1, borderTopColor: C.border, backgroundColor: C.paper },
|
||||
recordHint: { flex: 1, fontSize: 13, color: '#e11d48', fontWeight: '500' },
|
||||
aiBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: C.ink, paddingVertical: 11, paddingHorizontal: 14, borderRadius: 12, justifyContent: 'center' },
|
||||
aiBtnText: { color: C.white, fontWeight: '600', fontSize: 14 },
|
||||
})
|
||||
|
||||
177
memento-mobile/app/note/create.tsx
Normal file
177
memento-mobile/app/note/create.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import {
|
||||
View, Text, TextInput, TouchableOpacity, ScrollView,
|
||||
KeyboardAvoidingView, Platform, ActivityIndicator,
|
||||
Alert, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { Sparkles, X } from 'lucide-react-native'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { C } from '@/lib/theme'
|
||||
import { AISheet } from '@/components/AISheet'
|
||||
import { TitleSheet } from '@/components/TitleSheet'
|
||||
import { MicButton } from '@/components/MicButton'
|
||||
import { useAudioRecorder } from '@/lib/useAudioRecorder'
|
||||
|
||||
export default function CreateNoteScreen() {
|
||||
const router = useRouter()
|
||||
const { notebookId } = useLocalSearchParams<{ notebookId?: string }>()
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [aiSheetOpen, setAiSheetOpen] = useState(false)
|
||||
const [titleSheetOpen, setTitleSheetOpen] = useState(false)
|
||||
const contentRef = useRef<TextInput>(null)
|
||||
|
||||
// Audio
|
||||
const { state: audioState, startRecording, stopAndTranscribe, cancelRecording } = useAudioRecorder(
|
||||
(text) => setContent((prev) => prev ? prev + ' ' + text : text)
|
||||
)
|
||||
|
||||
const handleMic = () => {
|
||||
if (audioState === 'idle' || audioState === 'error') startRecording()
|
||||
else if (audioState === 'recording') stopAndTranscribe()
|
||||
else cancelRecording()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
Alert.alert('Titre requis', 'Donnez un titre à votre note.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.createNote, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: title.trim(), content, notebookId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}))
|
||||
throw new Error(d.error ?? 'Erreur serveur')
|
||||
}
|
||||
const { note } = await res.json()
|
||||
router.replace({ pathname: '/note/[id]', params: { id: note.id } })
|
||||
} catch (e: any) {
|
||||
Alert.alert('Erreur', e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.safe}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
|
||||
|
||||
{/* Header */}
|
||||
<View style={s.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={s.cancelBtn}>
|
||||
<X size={20} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
<Text style={s.headerTitle}>Nouvelle note</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleSave}
|
||||
disabled={saving || !title.trim()}
|
||||
style={[s.saveBtn, (!title.trim() || saving) && s.saveBtnDisabled]}
|
||||
>
|
||||
{saving
|
||||
? <ActivityIndicator size="small" color={C.white} />
|
||||
: <Text style={s.saveBtnText}>Enregistrer</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled">
|
||||
{/* Titre */}
|
||||
<View style={s.titleRow}>
|
||||
<TextInput
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Titre de la note…"
|
||||
style={s.titleInput}
|
||||
placeholderTextColor={C.border}
|
||||
returnKeyType="next"
|
||||
onSubmitEditing={() => contentRef.current?.focus()}
|
||||
autoFocus
|
||||
/>
|
||||
{content.trim().length >= 10 && (
|
||||
<TouchableOpacity onPress={() => setTitleSheetOpen(true)} style={s.sparkleBtn} activeOpacity={0.8}>
|
||||
<Sparkles size={16} color={C.brand} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={s.divider} />
|
||||
|
||||
{/* Contenu */}
|
||||
<TextInput
|
||||
ref={contentRef}
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="Commencez à écrire…"
|
||||
style={s.contentInput}
|
||||
placeholderTextColor={C.concrete}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* Barre outils bas */}
|
||||
<View style={s.toolbar}>
|
||||
<MicButton state={audioState} onPress={handleMic} />
|
||||
{audioState === 'recording' && (
|
||||
<Text style={s.recordingHint}>● Enregistrement… Appuyez pour arrêter</Text>
|
||||
)}
|
||||
{audioState !== 'recording' && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setAiSheetOpen(true)}
|
||||
disabled={!content.trim()}
|
||||
style={[s.aiBtn, !content.trim() && s.aiBtnDisabled]}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Sparkles size={15} color={C.white} />
|
||||
<Text style={s.aiBtnText}>Améliorer avec l'IA</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Modaux propres */}
|
||||
<AISheet
|
||||
visible={aiSheetOpen}
|
||||
onClose={() => setAiSheetOpen(false)}
|
||||
text={content}
|
||||
onApply={(improved) => setContent(improved)}
|
||||
/>
|
||||
<TitleSheet
|
||||
visible={titleSheetOpen}
|
||||
onClose={() => setTitleSheetOpen(false)}
|
||||
content={content}
|
||||
onSelect={(t) => setTitle(t)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: C.paper },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: C.border },
|
||||
cancelBtn: { padding: 4, marginRight: 4 },
|
||||
headerTitle: { flex: 1, fontSize: 15, fontWeight: '600', color: C.ink, textAlign: 'center' },
|
||||
saveBtn: { backgroundColor: C.brand, paddingHorizontal: 14, paddingVertical: 7, borderRadius: 10 },
|
||||
saveBtnDisabled: { opacity: 0.35 },
|
||||
saveBtnText: { color: C.white, fontWeight: '700', fontSize: 13 },
|
||||
titleRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingTop: 20, paddingBottom: 8, gap: 8 },
|
||||
titleInput: { flex: 1, fontSize: 26, fontWeight: '800', color: C.ink, letterSpacing: -0.5, lineHeight: 32 },
|
||||
sparkleBtn: { padding: 8, borderRadius: 10, backgroundColor: '#f3ece4' },
|
||||
divider: { height: 1, backgroundColor: C.border, marginHorizontal: 20, marginBottom: 16 },
|
||||
contentInput: { flex: 1, fontSize: 16, color: C.ink, lineHeight: 26, paddingHorizontal: 20, paddingBottom: 120, minHeight: 300 },
|
||||
toolbar: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 12, borderTopWidth: 1, borderTopColor: C.border, backgroundColor: C.paper },
|
||||
recordingHint: { flex: 1, fontSize: 13, color: '#e11d48', fontWeight: '500' },
|
||||
aiBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6, backgroundColor: C.ink, paddingVertical: 11, paddingHorizontal: 14, borderRadius: 12, justifyContent: 'center' },
|
||||
aiBtnDisabled: { opacity: 0.35 },
|
||||
aiBtnText: { color: C.white, fontWeight: '600', fontSize: 14 },
|
||||
})
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ArrowLeft } from 'lucide-react-native'
|
||||
import { ArrowLeft, Plus } from 'lucide-react-native'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { C } from '../_layout'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Note {
|
||||
id: string
|
||||
@@ -46,6 +46,12 @@ export default function NotebookScreen() {
|
||||
<ArrowLeft size={22} color={C.ink} />
|
||||
</TouchableOpacity>
|
||||
<Text style={s.headerTitle}>{notebookName || 'Carnet'}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push({ pathname: '/note/create', params: { notebookId: id } })}
|
||||
style={s.addBtn}
|
||||
>
|
||||
<Plus size={20} color={C.brand} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{loading
|
||||
@@ -56,7 +62,7 @@ export default function NotebookScreen() {
|
||||
contentContainerStyle={s.list}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => { setRefreshing(true); load() }} tintColor={C.brand} />}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity onPress={() => router.push(`/note/${item.id}`)} style={s.card}>
|
||||
<TouchableOpacity onPress={() => router.push({ pathname: '/note/[id]', params: { id: item.id } })} style={s.card}>
|
||||
<Text style={s.cardTitle} numberOfLines={1}>{item.title || 'Sans titre'}</Text>
|
||||
<Text style={s.cardDate}>
|
||||
{new Date(item.updatedAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}
|
||||
@@ -73,6 +79,7 @@ const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: C.paper },
|
||||
header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: C.border },
|
||||
headerTitle: { fontSize: 17, fontWeight: '600', color: C.ink, flex: 1 },
|
||||
addBtn: { width: 34, height: 34, borderRadius: 17, backgroundColor: '#f3ece4', alignItems: 'center', justifyContent: 'center' },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
list: { paddingHorizontal: 20, paddingTop: 16, paddingBottom: 32 },
|
||||
card: { backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 16, padding: 16, marginBottom: 10 },
|
||||
|
||||
237
memento-mobile/app/revision/session.tsx
Normal file
237
memento-mobile/app/revision/session.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import {
|
||||
View, Text, StyleSheet, TouchableOpacity, ActivityIndicator,
|
||||
Animated, ScrollView,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ArrowLeft, GraduationCap } from 'lucide-react-native'
|
||||
import { C } from '@/lib/theme'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
|
||||
interface Card {
|
||||
id: string
|
||||
front: string
|
||||
back: string
|
||||
interval: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
type SessionState = 'loading' | 'reviewing' | 'done' | 'error'
|
||||
|
||||
const GRADE_LABELS: { grade: 1 | 2 | 3 | 4; label: string; color: string; bg: string; border: string }[] = [
|
||||
{ grade: 1, label: 'Oublié', color: '#dc2626', bg: 'rgba(239,68,68,0.10)', border: 'rgba(239,68,68,0.30)' },
|
||||
{ grade: 2, label: 'Difficile',color: '#b45309', bg: 'rgba(245,158,11,0.10)', border: 'rgba(245,158,11,0.30)' },
|
||||
{ grade: 3, label: 'Bien', color: '#047857', bg: 'rgba(16,185,129,0.10)', border: 'rgba(16,185,129,0.30)' },
|
||||
{ grade: 4, label: 'Parfait', color: '#A47148', bg: 'rgba(164,113,72,0.10)', border: 'rgba(164,113,72,0.30)' },
|
||||
]
|
||||
|
||||
export default function SessionScreen() {
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{ deckId: string; deckName: string }>()
|
||||
const { deckId, deckName } = params
|
||||
|
||||
const [state, setState] = useState<SessionState>('loading')
|
||||
const [cards, setCards] = useState<Card[]>([])
|
||||
const [index, setIndex] = useState(0)
|
||||
const [flipped, setFlipped] = useState(false)
|
||||
const [reviewed, setReviewed] = useState(0)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
|
||||
// Animation flip
|
||||
const flipAnim = useRef(new Animated.Value(0)).current
|
||||
const frontInterp = flipAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] })
|
||||
const backInterp = flipAnim.interpolate({ inputRange: [0, 1], outputRange: ['180deg', '360deg'] })
|
||||
|
||||
const loadSession = useCallback(async () => {
|
||||
if (!deckId) return
|
||||
setState('loading')
|
||||
setIndex(0)
|
||||
setFlipped(false)
|
||||
setReviewed(0)
|
||||
flipAnim.setValue(0)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.flashcardSession(deckId))
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error ?? `Erreur ${res.status}`)
|
||||
setCards(data.cards ?? [])
|
||||
setState(data.cards?.length === 0 ? 'done' : 'reviewing')
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message ?? 'Erreur')
|
||||
setState('error')
|
||||
}
|
||||
}, [deckId])
|
||||
|
||||
useFocusEffect(useCallback(() => { loadSession() }, [loadSession]))
|
||||
|
||||
const flip = () => {
|
||||
if (flipped) return
|
||||
setFlipped(true)
|
||||
Animated.spring(flipAnim, { toValue: 1, useNativeDriver: true, friction: 8 }).start()
|
||||
}
|
||||
|
||||
const grade = async (g: 1 | 2 | 3 | 4) => {
|
||||
const card = cards[index]
|
||||
if (!card) return
|
||||
try {
|
||||
await apiFetch(ENDPOINTS.flashcardReview, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cardId: card.id, grade: g }),
|
||||
})
|
||||
} catch { /* silencieux — on avance quand même */ }
|
||||
|
||||
const next = index + 1
|
||||
setReviewed((r) => r + 1)
|
||||
if (next >= cards.length) {
|
||||
setState('done')
|
||||
} else {
|
||||
setIndex(next)
|
||||
setFlipped(false)
|
||||
Animated.timing(flipAnim, { toValue: 0, duration: 0, useNativeDriver: true }).start()
|
||||
}
|
||||
}
|
||||
|
||||
const current = cards[index]
|
||||
const progress = cards.length > 0 ? index / cards.length : 0
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.safe}>
|
||||
{/* Header */}
|
||||
<View style={s.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<ArrowLeft size={22} color={C.ink} />
|
||||
</TouchableOpacity>
|
||||
<Text style={s.headerTitle} numberOfLines={1}>{deckName ?? 'Révision'}</Text>
|
||||
{state === 'reviewing' && (
|
||||
<Text style={s.counter}>{index + 1}/{cards.length}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Barre de progression */}
|
||||
{state === 'reviewing' && (
|
||||
<View style={s.progressBarWrap}>
|
||||
<View style={[s.progressBarFill, { width: `${Math.round(progress * 100)}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Contenu */}
|
||||
{state === 'loading' && (
|
||||
<View style={s.center}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<View style={s.center}>
|
||||
<Text style={s.errorTxt}>{errorMsg}</Text>
|
||||
<TouchableOpacity onPress={loadSession} style={s.retryBtn}>
|
||||
<Text style={s.retryTxt}>Réessayer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{state === 'done' && (
|
||||
<View style={s.center}>
|
||||
<GraduationCap size={56} color={C.brand} />
|
||||
<Text style={s.doneTitle}>Session terminée !</Text>
|
||||
<Text style={s.doneSub}>
|
||||
{reviewed > 0 ? `${reviewed} carte${reviewed > 1 ? 's' : ''} révisée${reviewed > 1 ? 's' : ''}` : 'Tout est à jour 🎉'}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => router.back()} style={s.doneBtn}>
|
||||
<Text style={s.doneBtnTxt}>Retour aux paquets</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{state === 'reviewing' && current && (
|
||||
<View style={s.sessionWrap}>
|
||||
{/* Carte flip */}
|
||||
<TouchableOpacity activeOpacity={0.95} onPress={flip} style={s.cardWrap}>
|
||||
{/* Face avant */}
|
||||
<Animated.View style={[s.card, s.cardFront, { transform: [{ rotateY: frontInterp }] }]}>
|
||||
<ScrollView contentContainerStyle={s.cardContent} showsVerticalScrollIndicator={false}>
|
||||
<Text style={s.cardLabel}>Question</Text>
|
||||
<Text style={s.cardText}>{current.front}</Text>
|
||||
</ScrollView>
|
||||
{!flipped && (
|
||||
<View style={s.tapHint}>
|
||||
<Text style={s.tapTxt}>Appuyez pour révéler la réponse</Text>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Face arrière */}
|
||||
<Animated.View style={[s.card, s.cardBack, { transform: [{ rotateY: backInterp }] }]}>
|
||||
<ScrollView contentContainerStyle={s.cardContent} showsVerticalScrollIndicator={false}>
|
||||
<Text style={s.cardLabel}>Réponse</Text>
|
||||
<Text style={s.cardText}>{current.back}</Text>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Boutons de note (visibles seulement après flip) */}
|
||||
{flipped && (
|
||||
<View style={s.gradeRow}>
|
||||
{GRADE_LABELS.map(({ grade: g, label, color, bg, border }) => (
|
||||
<TouchableOpacity
|
||||
key={g}
|
||||
style={[s.gradeBtn, { backgroundColor: bg, borderColor: border }]}
|
||||
onPress={() => grade(g)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[s.gradeNum, { color }]}>{g}</Text>
|
||||
<Text style={[s.gradeLbl, { color }]}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: C.paper },
|
||||
header: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||
paddingHorizontal: 20, paddingVertical: 14,
|
||||
borderBottomWidth: 1, borderBottomColor: C.border,
|
||||
},
|
||||
headerTitle: { flex: 1, fontSize: 17, fontWeight: '700', color: C.ink },
|
||||
counter: { fontSize: 13, color: C.concrete, fontWeight: '600' },
|
||||
progressBarWrap: { height: 3, backgroundColor: C.border },
|
||||
progressBarFill: { height: 3, backgroundColor: C.brand },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
|
||||
sessionWrap: { flex: 1, padding: 20, justifyContent: 'space-between' },
|
||||
cardWrap: { flex: 1, marginBottom: 16 },
|
||||
card: {
|
||||
position: 'absolute', inset: 0,
|
||||
borderRadius: 18, borderWidth: 1, borderColor: C.border,
|
||||
backgroundColor: C.surface,
|
||||
backfaceVisibility: 'hidden',
|
||||
padding: 24,
|
||||
},
|
||||
cardFront: { zIndex: 1 },
|
||||
cardBack: { backgroundColor: '#f0f7ff' },
|
||||
cardContent: { flexGrow: 1, justifyContent: 'center', alignItems: 'center', paddingBottom: 48 },
|
||||
cardLabel: { fontSize: 11, fontWeight: '700', color: C.brand, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 16 },
|
||||
cardText: { fontSize: 20, color: C.ink, textAlign: 'center', lineHeight: 30, fontWeight: '500' },
|
||||
tapHint: { position: 'absolute', bottom: 20, left: 0, right: 0, alignItems: 'center' },
|
||||
tapTxt: { fontSize: 12, color: C.concrete, fontStyle: 'italic' },
|
||||
gradeRow: { flexDirection: 'row', gap: 8 },
|
||||
gradeBtn: {
|
||||
flex: 1, alignItems: 'center', paddingVertical: 12, borderRadius: 14, borderWidth: 1.5,
|
||||
},
|
||||
gradeNum: { fontSize: 18, fontWeight: '800' },
|
||||
gradeLbl: { fontSize: 10, fontWeight: '600', marginTop: 2 },
|
||||
doneTitle: { fontSize: 24, fontWeight: '800', color: C.ink, marginTop: 20, marginBottom: 8 },
|
||||
doneSub: { fontSize: 15, color: C.concrete, marginBottom: 32 },
|
||||
doneBtn: { backgroundColor: C.brand, paddingHorizontal: 28, paddingVertical: 14, borderRadius: 14 },
|
||||
doneBtnTxt: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||
errorTxt: { fontSize: 15, color: '#e11d48', textAlign: 'center', marginBottom: 12 },
|
||||
retryBtn: { backgroundColor: C.brand, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 },
|
||||
retryTxt: { color: '#fff', fontWeight: '600' },
|
||||
})
|
||||
BIN
memento-mobile/assets/adaptive-icon.png
Normal file
BIN
memento-mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
memento-mobile/assets/icon.png
Normal file
BIN
memento-mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
memento-mobile/assets/splash.png
Normal file
BIN
memento-mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
142
memento-mobile/components/AISheet.tsx
Normal file
142
memento-mobile/components/AISheet.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* AISheet — bottom sheet IA avec modes d'amélioration + résultat
|
||||
* Remplace les Alert.alert natifs par une UI propre au design Memento
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
View, Text, TouchableOpacity, ActivityIndicator,
|
||||
ScrollView, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { Sparkles, Scissors, MessageSquare, Pencil, CheckCircle2, RefreshCw } from 'lucide-react-native'
|
||||
import { BottomSheet } from '@/components/BottomSheet'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
const MODES = [
|
||||
{ key: 'improve', label: 'Améliorer le style', icon: Sparkles, color: C.brand },
|
||||
{ key: 'fix_grammar', label: 'Corriger la grammaire', icon: CheckCircle2, color: '#5b7ec7' },
|
||||
{ key: 'shorten', label: 'Raccourcir', icon: Scissors, color: '#4a9b61' },
|
||||
{ key: 'clarify', label: 'Clarifier les idées', icon: MessageSquare,color: '#c77a3a' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
text: string
|
||||
onApply: (improved: string) => void
|
||||
}
|
||||
|
||||
export function AISheet({ visible, onClose, text, onApply }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<string | null>(null)
|
||||
const [selectedMode, setSelectedMode] = useState<string | null>(null)
|
||||
|
||||
const handleClose = () => {
|
||||
setResult(null)
|
||||
setSelectedMode(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleMode = async (mode: string) => {
|
||||
setSelectedMode(mode)
|
||||
setLoading(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.aiImprove, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text, mode }),
|
||||
})
|
||||
const d = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
setResult(`⚠️ ${d.error === 'quota_exceeded' ? 'Quota IA dépassé' : (d.error ?? 'Erreur serveur')}`)
|
||||
return
|
||||
}
|
||||
setResult(d.improved ?? '')
|
||||
} catch {
|
||||
setResult('⚠️ Erreur réseau — vérifiez votre connexion.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
if (result && !result.startsWith('⚠️')) {
|
||||
onApply(result)
|
||||
}
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
if (selectedMode) handleMode(selectedMode)
|
||||
}
|
||||
|
||||
const title = result ? 'Résultat IA' : 'Améliorer avec l\'IA'
|
||||
|
||||
return (
|
||||
<BottomSheet visible={visible} onClose={handleClose} title={title}>
|
||||
{!result && !loading && (
|
||||
<View style={s.modeList}>
|
||||
{MODES.map((m) => {
|
||||
const Icon = m.icon
|
||||
return (
|
||||
<TouchableOpacity key={m.key} onPress={() => handleMode(m.key)} style={s.modeRow} activeOpacity={0.7}>
|
||||
<View style={[s.modeIcon, { backgroundColor: m.color + '18' }]}>
|
||||
<Icon size={18} color={m.color} />
|
||||
</View>
|
||||
<Text style={s.modeLabel}>{m.label}</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<View style={s.loadingBox}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
<Text style={s.loadingText}>Génération en cours…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{result && !loading && (
|
||||
<View style={s.resultBox}>
|
||||
<ScrollView style={s.resultScroll} showsVerticalScrollIndicator={false}>
|
||||
<Text style={[s.resultText, result.startsWith('⚠️') && { color: '#e11d48' }]}>
|
||||
{result}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
{!result.startsWith('⚠️') && (
|
||||
<TouchableOpacity onPress={handleApply} style={s.applyBtn} activeOpacity={0.8}>
|
||||
<Pencil size={15} color={C.white} />
|
||||
<Text style={s.applyBtnText}>Remplacer le texte</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity onPress={handleRetry} style={s.retryBtn} activeOpacity={0.8}>
|
||||
<RefreshCw size={14} color={C.concrete} />
|
||||
<Text style={s.retryText}>Réessayer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
modeList: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 8 },
|
||||
modeRow: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: 14,
|
||||
paddingHorizontal: 12, paddingVertical: 14,
|
||||
borderRadius: 14, marginBottom: 4,
|
||||
},
|
||||
modeIcon: { width: 40, height: 40, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
|
||||
modeLabel: { fontSize: 15, fontWeight: '500', color: C.ink },
|
||||
loadingBox: { alignItems: 'center', paddingVertical: 36, gap: 12 },
|
||||
loadingText: { fontSize: 14, color: C.concrete },
|
||||
resultBox: { paddingHorizontal: 20, paddingTop: 8 },
|
||||
resultScroll: { maxHeight: 200, backgroundColor: C.white, borderWidth: 1, borderColor: C.border, borderRadius: 14, padding: 14, marginBottom: 14 },
|
||||
resultText: { fontSize: 15, color: C.ink, lineHeight: 23 },
|
||||
applyBtn: { flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: C.ink, paddingVertical: 13, borderRadius: 14, justifyContent: 'center', marginBottom: 8 },
|
||||
applyBtnText: { color: C.white, fontWeight: '700', fontSize: 14 },
|
||||
retryBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, justifyContent: 'center', paddingVertical: 10 },
|
||||
retryText: { fontSize: 13, color: C.concrete },
|
||||
})
|
||||
80
memento-mobile/components/BottomSheet.tsx
Normal file
80
memento-mobile/components/BottomSheet.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* BottomSheet — modal bas d'écran respectant le design Memento
|
||||
* Usage:
|
||||
* <BottomSheet visible={v} onClose={() => setV(false)} title="Titre">
|
||||
* ...children
|
||||
* </BottomSheet>
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
View, Text, Modal, TouchableOpacity, Animated,
|
||||
Pressable, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { X } from 'lucide-react-native'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function BottomSheet({ visible, onClose, title, children }: Props) {
|
||||
const translateY = useRef(new Animated.Value(400)).current
|
||||
const opacity = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.spring(translateY, { toValue: 0, useNativeDriver: true, damping: 20, stiffness: 200 }),
|
||||
Animated.timing(opacity, { toValue: 1, duration: 200, useNativeDriver: true }),
|
||||
]).start()
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(translateY, { toValue: 400, duration: 220, useNativeDriver: true }),
|
||||
Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
||||
]).start()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
|
||||
<Animated.View style={[s.overlay, { opacity }]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
|
||||
<Animated.View style={[s.sheet, { transform: [{ translateY }] }]}>
|
||||
{/* Handle bar */}
|
||||
<View style={s.handle} />
|
||||
{title && (
|
||||
<View style={s.titleRow}>
|
||||
<Text style={s.title}>{title}</Text>
|
||||
<TouchableOpacity onPress={onClose} style={s.closeBtn} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
<X size={18} color={C.concrete} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{children}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
overlay: { flex: 1, backgroundColor: 'rgba(26,26,24,0.5)', justifyContent: 'flex-end' },
|
||||
sheet: {
|
||||
backgroundColor: C.paper,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
paddingBottom: 32,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 16,
|
||||
elevation: 16,
|
||||
},
|
||||
handle: { width: 36, height: 4, backgroundColor: C.border, borderRadius: 2, alignSelf: 'center', marginTop: 12, marginBottom: 4 },
|
||||
titleRow: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 20, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: C.border },
|
||||
title: { flex: 1, fontSize: 15, fontWeight: '700', color: C.ink },
|
||||
closeBtn: { padding: 2 },
|
||||
})
|
||||
173
memento-mobile/components/FlashcardSheet.tsx
Normal file
173
memento-mobile/components/FlashcardSheet.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* FlashcardSheet — génère et sauvegarde des flashcards depuis une note
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView } from 'react-native'
|
||||
import { GraduationCap, Check, RefreshCw } from 'lucide-react-native'
|
||||
import { BottomSheet } from './BottomSheet'
|
||||
import { C } from '@/lib/theme'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { useRouter } from 'expo-router'
|
||||
|
||||
const STYLES = [
|
||||
{ key: 'qa', label: 'Q&A', desc: 'Questions / Réponses' },
|
||||
{ key: 'concept', label: 'Concept', desc: 'Terme / Définition' },
|
||||
{ key: 'cloze', label: 'Cloze', desc: 'Texte à trous' },
|
||||
] as const
|
||||
|
||||
const COUNTS = [5, 10, 15, 20]
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
}
|
||||
|
||||
export function FlashcardSheet({ visible, onClose, noteId, noteTitle }: Props) {
|
||||
const router = useRouter()
|
||||
const [style, setStyle] = useState<'qa' | 'concept' | 'cloze'>('qa')
|
||||
const [count, setCount] = useState(10)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<{ deckId: string; count: number } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const generate = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.flashcardGenerate, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ noteId, style, count }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error ?? `Erreur ${res.status}`)
|
||||
setResult({ deckId: data.deckId, count: data.count })
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? 'Erreur de génération')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const goRevise = () => {
|
||||
onClose()
|
||||
router.push({ pathname: '/(tabs)/revision' })
|
||||
}
|
||||
|
||||
const reset = () => { setResult(null); setError(null) }
|
||||
|
||||
return (
|
||||
<BottomSheet visible={visible} onClose={onClose}>
|
||||
<View style={s.header}>
|
||||
<GraduationCap size={20} color={C.brand} />
|
||||
<Text style={s.title}>Générer des flashcards</Text>
|
||||
</View>
|
||||
<Text style={s.noteTitle} numberOfLines={1}>📝 {noteTitle}</Text>
|
||||
|
||||
{!result && !loading && (
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Style */}
|
||||
<Text style={s.sectionLabel}>Type de cartes</Text>
|
||||
<View style={s.pills}>
|
||||
{STYLES.map((st) => (
|
||||
<TouchableOpacity
|
||||
key={st.key}
|
||||
style={[s.pill, style === st.key && s.pillActive]}
|
||||
onPress={() => setStyle(st.key)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[s.pillLabel, style === st.key && s.pillLabelActive]}>{st.label}</Text>
|
||||
<Text style={[s.pillDesc, style === st.key && s.pillDescActive]}>{st.desc}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Nombre */}
|
||||
<Text style={s.sectionLabel}>Nombre de cartes</Text>
|
||||
<View style={s.countRow}>
|
||||
{COUNTS.map((n) => (
|
||||
<TouchableOpacity
|
||||
key={n}
|
||||
style={[s.countBtn, count === n && s.countBtnActive]}
|
||||
onPress={() => setCount(n)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[s.countTxt, count === n && s.countTxtActive]}>{n}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{error && <Text style={s.error}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity style={s.generateBtn} onPress={generate} activeOpacity={0.85}>
|
||||
<GraduationCap size={16} color="#fff" />
|
||||
<Text style={s.generateTxt}>Générer {count} cartes</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<View style={s.center}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
<Text style={s.loadingTxt}>Génération en cours…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<View style={s.resultWrap}>
|
||||
<View style={s.checkCircle}>
|
||||
<Check size={28} color="#16a34a" />
|
||||
</View>
|
||||
<Text style={s.resultTitle}>{result.count} cartes créées !</Text>
|
||||
<Text style={s.resultSub}>Votre paquet est prêt pour la révision.</Text>
|
||||
<View style={s.resultActions}>
|
||||
<TouchableOpacity style={s.reviseBtn} onPress={goRevise} activeOpacity={0.85}>
|
||||
<GraduationCap size={16} color="#fff" />
|
||||
<Text style={s.reviseTxt}>Réviser maintenant</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.retryBtn} onPress={reset} activeOpacity={0.8}>
|
||||
<RefreshCw size={14} color={C.concrete} />
|
||||
<Text style={s.retryTxt}>Regénérer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
header: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 },
|
||||
title: { fontSize: 17, fontWeight: '700', color: C.ink },
|
||||
noteTitle: { fontSize: 13, color: C.concrete, marginBottom: 20 },
|
||||
sectionLabel: { fontSize: 11, fontWeight: '700', color: C.concrete, letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 10 },
|
||||
pills: { flexDirection: 'row', gap: 8, marginBottom: 20 },
|
||||
pill: { flex: 1, padding: 12, borderRadius: 12, borderWidth: 1.5, borderColor: C.border, backgroundColor: C.paper },
|
||||
pillActive: { borderColor: C.brand, backgroundColor: 'rgba(164,113,72,0.08)' },
|
||||
pillLabel: { fontSize: 13, fontWeight: '700', color: C.ink, marginBottom: 2 },
|
||||
pillLabelActive: { color: C.brand },
|
||||
pillDesc: { fontSize: 10, color: C.concrete },
|
||||
pillDescActive: { color: C.brand },
|
||||
countRow: { flexDirection: 'row', gap: 8, marginBottom: 24 },
|
||||
countBtn: { flex: 1, paddingVertical: 12, borderRadius: 12, borderWidth: 1.5, borderColor: C.border, alignItems: 'center', backgroundColor: C.paper },
|
||||
countBtnActive: { borderColor: C.brand, backgroundColor: 'rgba(164,113,72,0.08)' },
|
||||
countTxt: { fontSize: 15, fontWeight: '700', color: C.ink },
|
||||
countTxtActive: { color: C.brand },
|
||||
error: { color: '#e11d48', fontSize: 13, marginBottom: 12, textAlign: 'center' },
|
||||
generateBtn: { backgroundColor: C.brand, borderRadius: 14, paddingVertical: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8 },
|
||||
generateTxt: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||
center: { alignItems: 'center', justifyContent: 'center', paddingVertical: 40 },
|
||||
loadingTxt: { marginTop: 16, color: C.concrete, fontSize: 14 },
|
||||
resultWrap: { alignItems: 'center', paddingVertical: 20 },
|
||||
checkCircle: { width: 64, height: 64, borderRadius: 32, backgroundColor: '#dcfce7', alignItems: 'center', justifyContent: 'center', marginBottom: 16 },
|
||||
resultTitle: { fontSize: 20, fontWeight: '800', color: C.ink, marginBottom: 8 },
|
||||
resultSub: { fontSize: 14, color: C.concrete, marginBottom: 28 },
|
||||
resultActions: { width: '100%', gap: 10 },
|
||||
reviseBtn: { backgroundColor: C.brand, borderRadius: 14, paddingVertical: 14, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8 },
|
||||
reviseTxt: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||
retryBtn: { borderRadius: 14, paddingVertical: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, borderWidth: 1, borderColor: C.border },
|
||||
retryTxt: { color: C.concrete, fontWeight: '600', fontSize: 13 },
|
||||
})
|
||||
66
memento-mobile/components/MicButton.tsx
Normal file
66
memento-mobile/components/MicButton.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* MicButton — bouton enregistrement vocal avec feedback visuel
|
||||
* États : idle → recording (pulsé rouge) → processing (spinner)
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { TouchableOpacity, ActivityIndicator, Animated, StyleSheet, View, Text } from 'react-native'
|
||||
import { Mic, MicOff, Square } from 'lucide-react-native'
|
||||
import { AudioState } from '@/lib/useAudioRecorder'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Props {
|
||||
state: AudioState
|
||||
onPress: () => void
|
||||
errorMsg?: string | null
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function MicButton({ state, onPress, errorMsg, size = 20 }: Props) {
|
||||
const pulse = useRef(new Animated.Value(1)).current
|
||||
|
||||
useEffect(() => {
|
||||
if (state === 'recording') {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 1.25, duration: 600, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 1, duration: 600, useNativeDriver: true }),
|
||||
])
|
||||
).start()
|
||||
} else {
|
||||
pulse.stopAnimation()
|
||||
pulse.setValue(1)
|
||||
}
|
||||
}, [state])
|
||||
|
||||
const bgColor =
|
||||
state === 'recording' ? '#fee2e2' :
|
||||
state === 'processing' ? '#f3ece4' :
|
||||
state === 'error' ? '#fee2e2' :
|
||||
'#f3ece4'
|
||||
|
||||
const borderColor =
|
||||
state === 'recording' ? '#fca5a5' :
|
||||
state === 'error' ? '#fca5a5' :
|
||||
C.border
|
||||
|
||||
return (
|
||||
<Animated.View style={[s.wrap, { backgroundColor: bgColor, borderColor }, { transform: [{ scale: state === 'recording' ? pulse : 1 }] }]}>
|
||||
<TouchableOpacity onPress={onPress} disabled={state === 'processing'} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
||||
{state === 'processing'
|
||||
? <ActivityIndicator size="small" color={C.brand} />
|
||||
: state === 'recording'
|
||||
? <Square size={size - 2} color="#e11d48" />
|
||||
: state === 'error'
|
||||
? <MicOff size={size} color="#e11d48" />
|
||||
: <Mic size={size} color={C.brand} />}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
wrap: {
|
||||
width: 40, height: 40, borderRadius: 12,
|
||||
borderWidth: 1, alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
115
memento-mobile/components/TitleSheet.tsx
Normal file
115
memento-mobile/components/TitleSheet.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* TitleSheet — suggère 3 titres IA dans un bottom sheet propre
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
View, Text, TouchableOpacity, ActivityIndicator, StyleSheet,
|
||||
} from 'react-native'
|
||||
import { Sparkles } from 'lucide-react-native'
|
||||
import { BottomSheet } from '@/components/BottomSheet'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
import { C } from '@/lib/theme'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
content: string
|
||||
onSelect: (title: string) => void
|
||||
}
|
||||
|
||||
export function TitleSheet({ visible, onClose, content, onSelect }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [suggestions, setSuggestions] = useState<string[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && content.trim()) {
|
||||
fetchTitles()
|
||||
}
|
||||
}, [visible, content])
|
||||
|
||||
const fetchTitles = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuggestions([])
|
||||
try {
|
||||
const res = await apiFetch(ENDPOINTS.aiTitle, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
const d = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
setError(d.error === 'quota_exceeded' ? 'Quota dépassé' : 'Erreur serveur')
|
||||
return
|
||||
}
|
||||
const raw: unknown[] = d.suggestions ?? []
|
||||
setSuggestions(raw.map((s) => (typeof s === 'string' ? s : (s as any).title ?? '')).filter(Boolean))
|
||||
} catch {
|
||||
setError('Erreur réseau')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (t: string) => {
|
||||
onSelect(t)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomSheet visible={visible} onClose={onClose} title="Titres suggérés">
|
||||
{loading && (
|
||||
<View style={s.center}>
|
||||
<ActivityIndicator color={C.brand} size="large" />
|
||||
<Text style={s.hint}>Génération des titres…</Text>
|
||||
</View>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<View style={s.center}>
|
||||
<Text style={s.errorText}>{error}</Text>
|
||||
<TouchableOpacity onPress={fetchTitles} style={s.retryBtn}>
|
||||
<Text style={s.retryText}>Réessayer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{!loading && !error && suggestions.length > 0 && (
|
||||
<View style={s.list}>
|
||||
{suggestions.map((t, i) => (
|
||||
<TouchableOpacity key={i} onPress={() => handleSelect(t)} style={s.row} activeOpacity={0.7}>
|
||||
<View style={s.numBadge}>
|
||||
<Text style={s.numText}>{i + 1}</Text>
|
||||
</View>
|
||||
<Text style={s.titleText}>{t}</Text>
|
||||
<Sparkles size={14} color={C.brand} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{!loading && !error && suggestions.length === 0 && (
|
||||
<View style={s.center}>
|
||||
<Text style={s.hint}>Aucune suggestion disponible</Text>
|
||||
</View>
|
||||
)}
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
center: { alignItems: 'center', paddingVertical: 32, gap: 12 },
|
||||
hint: { fontSize: 14, color: C.concrete },
|
||||
errorText: { fontSize: 14, color: '#e11d48' },
|
||||
retryBtn: { paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10, backgroundColor: C.border },
|
||||
retryText: { fontSize: 13, color: C.ink, fontWeight: '600' },
|
||||
list: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 8 },
|
||||
row: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: 12,
|
||||
paddingHorizontal: 12, paddingVertical: 14,
|
||||
borderRadius: 14, marginBottom: 8,
|
||||
backgroundColor: C.white, borderWidth: 1, borderColor: C.border,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
numBadge: { width: 26, height: 26, borderRadius: 13, backgroundColor: '#f3ece4', alignItems: 'center', justifyContent: 'center' },
|
||||
numText: { fontSize: 12, fontWeight: '700', color: C.brand },
|
||||
titleText: { flex: 1, fontSize: 14, fontWeight: '500', color: C.ink },
|
||||
})
|
||||
@@ -3,11 +3,19 @@ import * as SecureStore from 'expo-secure-store'
|
||||
const TOKEN_KEY = 'memento_token'
|
||||
|
||||
export async function getToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync(TOKEN_KEY)
|
||||
try {
|
||||
return await SecureStore.getItemAsync(TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function setToken(token: string): Promise<void> {
|
||||
await SecureStore.setItemAsync(TOKEN_KEY, token)
|
||||
try {
|
||||
await SecureStore.setItemAsync(TOKEN_KEY, token)
|
||||
} catch (e) {
|
||||
console.warn('[SecureStore] setToken failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearToken(): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// API base URL — change for dev/prod
|
||||
export const API_URL = __DEV__
|
||||
? 'http://192.168.1.190:3000' // local network dev server
|
||||
? 'http://192.168.1.83:3000' // serveur de dev local
|
||||
: 'https://memento-note.com'
|
||||
|
||||
export const ENDPOINTS = {
|
||||
@@ -13,6 +13,14 @@ export const ENDPOINTS = {
|
||||
? `${API_URL}/api/mobile/notes?notebookId=${notebookId}`
|
||||
: `${API_URL}/api/mobile/notes`,
|
||||
note: (id: string) => `${API_URL}/api/mobile/notes/${id}`,
|
||||
createNote: `${API_URL}/api/mobile/notes`,
|
||||
search: `${API_URL}/api/mobile/search`,
|
||||
dailyNote: `${API_URL}/api/notes/daily`,
|
||||
dailyNote: `${API_URL}/api/mobile/notes/daily`,
|
||||
aiImprove: `${API_URL}/api/mobile/ai/improve`,
|
||||
aiTitle: `${API_URL}/api/mobile/ai/title`,
|
||||
aiTranscribe: `${API_URL}/api/mobile/ai/transcribe`,
|
||||
flashcardDecks: `${API_URL}/api/mobile/flashcards/decks`,
|
||||
flashcardSession: (deckId: string) => `${API_URL}/api/mobile/flashcards/session?deckId=${deckId}`,
|
||||
flashcardReview: `${API_URL}/api/mobile/flashcards/review`,
|
||||
flashcardGenerate: `${API_URL}/api/mobile/flashcards/generate`,
|
||||
}
|
||||
|
||||
176
memento-mobile/lib/i18n.ts
Normal file
176
memento-mobile/lib/i18n.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { getLocales } from 'expo-localization'
|
||||
import { I18nManager } from 'react-native'
|
||||
|
||||
export type MobileLang = 'fr' | 'en' | 'fa'
|
||||
|
||||
const translations: Record<MobileLang, Record<string, string>> = {
|
||||
fr: {
|
||||
'tab.home': 'Accueil',
|
||||
'tab.notebooks': 'Carnets',
|
||||
'tab.revision': 'Révision',
|
||||
'tab.search': 'Recherche',
|
||||
'tab.profile': 'Profil',
|
||||
'common.error': 'Erreur',
|
||||
'common.loading': 'Chargement…',
|
||||
'common.save': 'Enregistrer',
|
||||
'common.delete': 'Supprimer',
|
||||
'common.cancel': 'Annuler',
|
||||
'common.search': 'Rechercher',
|
||||
'common.settings': 'Paramètres',
|
||||
'common.logout': 'Déconnexion',
|
||||
'note.title': 'Titre…',
|
||||
'note.contentPlaceholder': 'Contenu…',
|
||||
'note.deleteTitle': 'Supprimer la note',
|
||||
'note.deleteConfirm': 'Voulez-vous vraiment supprimer cette note ?',
|
||||
'note.saveError': 'Erreur de sauvegarde',
|
||||
'note.deleteError': 'Impossible de supprimer la note',
|
||||
'note.dayNote': 'Note du jour',
|
||||
'note.dayNoteError': 'Impossible de charger la note du jour.',
|
||||
'note.editTitleOnly': "Le contenu enrichi ne peut pas être édité depuis mobile. Utilisez la version web pour modifier le texte.",
|
||||
'note.saveTitle': 'Enregistrer le titre',
|
||||
'note.saving': 'Sauvegarde…',
|
||||
'auth.loginFailed': 'Connexion échouée',
|
||||
'auth.googleFailed': 'Connexion Google échouée',
|
||||
'auth.serverError': 'Erreur serveur',
|
||||
'auth.unauthorized': 'Non autorisé',
|
||||
'auth.emailPlaceholder': 'Email',
|
||||
'auth.passwordPlaceholder': 'Mot de passe',
|
||||
'auth.loginButton': 'Se connecter',
|
||||
'auth.orGoogle': 'ou',
|
||||
'revision.title': 'Révision',
|
||||
'revision.empty': 'Aucune carte à réviser',
|
||||
'revision.loadingError': 'Erreur de chargement',
|
||||
'revision.difficult': 'Difficile',
|
||||
'revision.hard': 'Dur',
|
||||
'revision.good': 'Bien',
|
||||
'revision.easy': 'Facile',
|
||||
'profile.title': 'Profil',
|
||||
'search.placeholder': 'Rechercher des notes…',
|
||||
'search.noResults': 'Aucun résultat',
|
||||
'ai.improve': "✨ Améliorer avec l'IA",
|
||||
},
|
||||
en: {
|
||||
'tab.home': 'Home',
|
||||
'tab.notebooks': 'Notebooks',
|
||||
'tab.revision': 'Review',
|
||||
'tab.search': 'Search',
|
||||
'tab.profile': 'Profile',
|
||||
'common.error': 'Error',
|
||||
'common.loading': 'Loading…',
|
||||
'common.save': 'Save',
|
||||
'common.delete': 'Delete',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.search': 'Search',
|
||||
'common.settings': 'Settings',
|
||||
'common.logout': 'Log out',
|
||||
'note.title': 'Title…',
|
||||
'note.contentPlaceholder': 'Content…',
|
||||
'note.deleteTitle': 'Delete note',
|
||||
'note.deleteConfirm': 'Are you sure you want to delete this note?',
|
||||
'note.saveError': 'Save error',
|
||||
'note.deleteError': 'Could not delete the note',
|
||||
'note.dayNote': 'Note of the day',
|
||||
'note.dayNoteError': 'Could not load the note of the day.',
|
||||
'note.editTitleOnly': 'Rich content cannot be edited from mobile. Use the web version to modify the text.',
|
||||
'note.saveTitle': 'Save title',
|
||||
'note.saving': 'Saving…',
|
||||
'auth.loginFailed': 'Login failed',
|
||||
'auth.googleFailed': 'Google sign-in failed',
|
||||
'auth.serverError': 'Server error',
|
||||
'auth.unauthorized': 'Unauthorized',
|
||||
'auth.emailPlaceholder': 'Email',
|
||||
'auth.passwordPlaceholder': 'Password',
|
||||
'auth.loginButton': 'Sign in',
|
||||
'auth.orGoogle': 'or',
|
||||
'revision.title': 'Review',
|
||||
'revision.empty': 'No cards to review',
|
||||
'revision.loadingError': 'Loading error',
|
||||
'revision.difficult': 'Hard',
|
||||
'revision.hard': 'Difficult',
|
||||
'revision.good': 'Good',
|
||||
'revision.easy': 'Easy',
|
||||
'profile.title': 'Profile',
|
||||
'search.placeholder': 'Search notes…',
|
||||
'search.noResults': 'No results',
|
||||
'ai.improve': '✨ Improve with AI',
|
||||
},
|
||||
fa: {
|
||||
'tab.home': 'خانه',
|
||||
'tab.notebooks': 'دفترچهها',
|
||||
'tab.revision': 'مرور',
|
||||
'tab.search': 'جستجو',
|
||||
'tab.profile': 'پروفایل',
|
||||
'common.error': 'خطا',
|
||||
'common.loading': 'در حال بارگذاری…',
|
||||
'common.save': 'ذخیره',
|
||||
'common.delete': 'حذف',
|
||||
'common.cancel': 'انصراف',
|
||||
'common.search': 'جستجو',
|
||||
'common.settings': 'تنظیمات',
|
||||
'common.logout': 'خروج',
|
||||
'note.title': 'عنوان…',
|
||||
'note.contentPlaceholder': 'محتوا…',
|
||||
'note.deleteTitle': 'حذف یادداشت',
|
||||
'note.deleteConfirm': 'آیا از حذف این یادداشت مطمئن هستید؟',
|
||||
'note.saveError': 'خطای ذخیرهسازی',
|
||||
'note.deleteError': 'حذف یادداشت امکانپذیر نیست',
|
||||
'note.dayNote': 'یادداشت روز',
|
||||
'note.dayNoteError': 'بارگذاری یادداشت روز امکانپذیر نیست.',
|
||||
'note.editTitleOnly': 'محتوای غنی از موبایل قابل ویرایش نیست. برای ویرایش متن از نسخه وب استفاده کنید.',
|
||||
'note.saveTitle': 'ذخیره عنوان',
|
||||
'note.saving': 'در حال ذخیره…',
|
||||
'auth.loginFailed': 'ورود ناموفق',
|
||||
'auth.googleFailed': 'ورود با گوگل ناموفق',
|
||||
'auth.serverError': 'خطای سرور',
|
||||
'auth.unauthorized': 'غیر مجاز',
|
||||
'auth.emailPlaceholder': 'ایمیل',
|
||||
'auth.passwordPlaceholder': 'رمز عبور',
|
||||
'auth.loginButton': 'ورود',
|
||||
'auth.orGoogle': 'یا',
|
||||
'revision.title': 'مرور',
|
||||
'revision.empty': 'کارت مروری موجود نیست',
|
||||
'revision.loadingError': 'خطای بارگذاری',
|
||||
'revision.difficult': 'سخت',
|
||||
'revision.hard': 'دشوار',
|
||||
'revision.good': 'خوب',
|
||||
'revision.easy': 'آسان',
|
||||
'profile.title': 'پروفایل',
|
||||
'search.placeholder': 'جستجوی یادداشت…',
|
||||
'search.noResults': 'نتیجهای یافت نشد',
|
||||
'ai.improve': '✨ بهبود با هوش مصنوعی',
|
||||
},
|
||||
}
|
||||
|
||||
const RTL_LANGS: MobileLang[] = ['fa', 'ar']
|
||||
|
||||
function detectLang(): MobileLang {
|
||||
try {
|
||||
const locales = getLocales()
|
||||
const lang = locales[0]?.languageCode ?? 'fr'
|
||||
if (lang === 'fa') return 'fa'
|
||||
if (lang === 'en') return 'en'
|
||||
return 'fr'
|
||||
} catch {
|
||||
return 'fr'
|
||||
}
|
||||
}
|
||||
|
||||
let currentLang: MobileLang = detectLang()
|
||||
|
||||
export function setLang(lang: MobileLang) {
|
||||
currentLang = lang
|
||||
const isRtl = RTL_LANGS.includes(lang)
|
||||
I18nManager.forceRTL(isRtl)
|
||||
}
|
||||
|
||||
export function isRTL(): boolean {
|
||||
return RTL_LANGS.includes(currentLang)
|
||||
}
|
||||
|
||||
export function getLang(): MobileLang {
|
||||
return currentLang
|
||||
}
|
||||
|
||||
export function t(key: string): string {
|
||||
return translations[currentLang]?.[key] ?? translations.fr[key] ?? translations.en[key] ?? key
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface AuthState {
|
||||
user: User | null
|
||||
loading: boolean
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
loginWithToken: (token: string, user: User) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
restore: () => Promise<void>
|
||||
}
|
||||
@@ -28,7 +29,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Identifiants invalides')
|
||||
}
|
||||
const { token, user } = await res.json()
|
||||
@@ -36,7 +37,13 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
set({ user })
|
||||
},
|
||||
|
||||
loginWithToken: async (token, user) => {
|
||||
await setToken(token)
|
||||
set({ user })
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try { await apiFetch(ENDPOINTS.logout, { method: 'POST' }) } catch {}
|
||||
await clearToken()
|
||||
set({ user: null })
|
||||
},
|
||||
|
||||
18
memento-mobile/lib/theme.ts
Normal file
18
memento-mobile/lib/theme.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Design tokens partagés — ne pas importer depuis _layout pour éviter les circularités
|
||||
export const C = {
|
||||
brand: '#A47148',
|
||||
ink: '#1A1A18',
|
||||
paper: '#FAFAF8',
|
||||
concrete: '#8A8A82',
|
||||
border: '#E8E6E0',
|
||||
white: '#FFFFFF',
|
||||
surface: '#F5F4F0',
|
||||
rose: '#e11d48',
|
||||
roseBg: '#fff1f2',
|
||||
roseBorder: '#fecdd3',
|
||||
emerald: '#10b981',
|
||||
emeraldBg: '#ecfdf5',
|
||||
blue: '#3b82f6',
|
||||
amber: '#f59e0b',
|
||||
purple: '#8b5cf6',
|
||||
}
|
||||
100
memento-mobile/lib/useAudioRecorder.ts
Normal file
100
memento-mobile/lib/useAudioRecorder.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Audio } from 'expo-av'
|
||||
import { getToken } from '@/lib/api'
|
||||
import { ENDPOINTS } from '@/lib/config'
|
||||
|
||||
export type AudioState = 'idle' | 'recording' | 'processing' | 'error'
|
||||
|
||||
export function useAudioRecorder(onTranscript: (text: string) => void) {
|
||||
const [state, setState] = useState<AudioState>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const recordingRef = useRef<Audio.Recording | null>(null)
|
||||
|
||||
const startRecording = async () => {
|
||||
setErrorMsg(null)
|
||||
try {
|
||||
// Demande de permission
|
||||
const { granted } = await Audio.requestPermissionsAsync()
|
||||
if (!granted) {
|
||||
setErrorMsg('Permission micro refusée')
|
||||
setState('error')
|
||||
setTimeout(() => setState('idle'), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
await Audio.setAudioModeAsync({
|
||||
allowsRecordingIOS: true,
|
||||
playsInSilentModeIOS: true,
|
||||
})
|
||||
|
||||
const { recording } = await Audio.Recording.createAsync(
|
||||
Audio.RecordingOptionsPresets.HIGH_QUALITY
|
||||
)
|
||||
recordingRef.current = recording
|
||||
setState('recording')
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message ?? 'Impossible de démarrer le micro')
|
||||
setState('error')
|
||||
setTimeout(() => setState('idle'), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const stopAndTranscribe = async () => {
|
||||
const recording = recordingRef.current
|
||||
if (!recording) { setState('idle'); return }
|
||||
|
||||
setState('processing')
|
||||
recordingRef.current = null
|
||||
|
||||
try {
|
||||
// Sauvegarder l'URI AVANT d'unload
|
||||
const uri = recording.getURI()
|
||||
await recording.stopAndUnloadAsync()
|
||||
|
||||
// Rétablir le mode audio normal
|
||||
await Audio.setAudioModeAsync({ allowsRecordingIOS: false })
|
||||
|
||||
if (!uri) throw new Error('Fichier audio vide')
|
||||
|
||||
const token = await getToken()
|
||||
|
||||
// FormData RN : objet {uri, name, type}
|
||||
const form = new FormData()
|
||||
form.append('audio', { uri, name: 'audio.m4a', type: 'audio/m4a' } as any)
|
||||
|
||||
const res = await fetch(ENDPOINTS.aiTranscribe, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token ?? ''}` },
|
||||
body: form,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}))
|
||||
throw new Error(d.error ?? `Erreur ${res.status}`)
|
||||
}
|
||||
|
||||
const { text } = await res.json()
|
||||
if (text?.trim()) onTranscript(text.trim())
|
||||
setState('idle')
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message ?? 'Erreur transcription')
|
||||
setState('error')
|
||||
setTimeout(() => { setState('idle'); setErrorMsg(null) }, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelRecording = async () => {
|
||||
const recording = recordingRef.current
|
||||
recordingRef.current = null
|
||||
if (recording) {
|
||||
try {
|
||||
await recording.stopAndUnloadAsync()
|
||||
await Audio.setAudioModeAsync({ allowsRecordingIOS: false })
|
||||
} catch {}
|
||||
}
|
||||
setState('idle')
|
||||
setErrorMsg(null)
|
||||
}
|
||||
|
||||
return { state, errorMsg, startRecording, stopAndTranscribe, cancelRecording }
|
||||
}
|
||||
29
memento-mobile/package-lock.json
generated
29
memento-mobile/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"expo": "~54.0.35",
|
||||
"expo-av": "^16.0.8",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-font": "~14.0.12",
|
||||
"expo-linking": "~8.0.12",
|
||||
@@ -17,6 +18,7 @@
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-web-browser": "~14.1.6",
|
||||
"lucide-react-native": "^0.477.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
@@ -4768,6 +4770,23 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-av": {
|
||||
"version": "16.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz",
|
||||
"integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-web": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-web": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.13",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||
@@ -4985,6 +5004,16 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-web-browser": {
|
||||
"version": "14.1.6",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.1.6.tgz",
|
||||
"integrity": "sha512-/4P8eWqRyfXIMZna3acg320LXNA+P2cwyEVbjDX8vHnWU+UnOtyRKWy3XaAIyMPQ9hVjBNUQTh4MPvtnPRzakw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/exponential-backoff": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user